@morscherlab/mint-sdk 1.2.0-beta.15 → 1.2.0-beta.16
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-BIZKoRTT.js → ExperimentPopover-CJdNlCZb.js} +3 -3
- package/dist/{ExperimentPopover-BIZKoRTT.js.map → ExperimentPopover-CJdNlCZb.js.map} +1 -1
- package/dist/{ExperimentSelectorModal-BeEOomw_.js → ExperimentSelectorModal-CZ1f_K9I.js} +5 -5
- package/dist/{ExperimentSelectorModal-BeEOomw_.js.map → ExperimentSelectorModal-CZ1f_K9I.js.map} +1 -1
- package/dist/{SettingsModal-Dphh3RrI.js → SettingsModal-DQUcpT4T.js} +5 -5
- package/dist/{SettingsModal-Dphh3RrI.js.map → SettingsModal-DQUcpT4T.js.map} +1 -1
- package/dist/{Skeleton-DxtwJ5gt.js → Skeleton-JaDtC8-9.js} +3 -3
- package/dist/{Skeleton-DxtwJ5gt.js.map → Skeleton-JaDtC8-9.js.map} +1 -1
- package/dist/__tests__/apiErrors.test.d.ts +1 -0
- package/dist/__tests__/components/PlotlyChart.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 +1 -1
- package/dist/components/FilePicker.vue.d.ts +3 -3
- 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/index.d.ts +1 -0
- package/dist/components/index.js +8 -8
- package/dist/{components-6IIxCSI8.js → components-DT6ggNrt.js} +580 -366
- package/dist/components-DT6ggNrt.js.map +1 -0
- package/dist/composables/filePicker/localFiles.d.ts +1 -1
- package/dist/composables/filePicker/usePickerNavigation.d.ts +4 -3
- package/dist/composables/index.js +9 -9
- package/dist/composables/useApi.d.ts +2 -0
- package/dist/composables/useFilePicker.d.ts +5 -6
- package/dist/{composables-hY2Q2xPI.js → composables-DmF_8iyX.js} +13 -10
- package/dist/{composables-hY2Q2xPI.js.map → composables-DmF_8iyX.js.map} +1 -1
- package/dist/{errors-_W0-LbA6.js → errors-DZDb1cIb.js} +66 -5
- package/dist/errors-DZDb1cIb.js.map +1 -0
- package/dist/{experiment-Cwl9AYxw.js → experiment-CHm_TwJy.js} +2 -2
- package/dist/{experiment-Cwl9AYxw.js.map → experiment-CHm_TwJy.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 +29 -18
- package/dist/templates/index.js +2 -2
- package/dist/{templates-Qq113pyZ.js → templates-CXARYr6_.js} +2 -2
- package/dist/{templates-Qq113pyZ.js.map → templates-CXARYr6_.js.map} +1 -1
- package/dist/types/filePicker.d.ts +9 -1
- package/dist/{useControlSchema-aMZlb4Sc.js → useControlSchema-BAzmP1_y.js} +42 -37
- package/dist/useControlSchema-BAzmP1_y.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-D_vobhnW.js} +4 -4
- package/dist/{useExperimentSelector-I8HC8hUu.js.map → useExperimentSelector-D_vobhnW.js.map} +1 -1
- package/dist/{useFormBuilder-TCxNnqeb.js → useFormBuilder-DEHejpiL.js} +2 -2
- package/dist/{useFormBuilder-TCxNnqeb.js.map → useFormBuilder-DEHejpiL.js.map} +1 -1
- package/dist/{validation-nBbSn1TI.js → validation-OuEr0iAS.js} +9 -8
- package/dist/{validation-nBbSn1TI.js.map → validation-OuEr0iAS.js.map} +1 -1
- package/package.json +1 -1
- package/src/__tests__/apiErrors.test.ts +81 -0
- package/src/__tests__/components/FilePicker.state.test.ts +128 -1
- package/src/__tests__/components/FilePicker.test.ts +146 -1
- 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__/composables/useApi.test.ts +82 -9
- package/src/__tests__/composables/useGeneratedAnalysis.test.ts +7 -2
- package/src/__tests__/composables/usePluginClient.test.ts +3 -0
- package/src/apiErrors.ts +85 -0
- package/src/components/FilePicker.story.vue +44 -0
- package/src/components/FilePicker.vue +23 -40
- 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/index.ts +1 -0
- package/src/composables/filePicker/localFiles.ts +6 -3
- package/src/composables/filePicker/usePickerNavigation.ts +29 -8
- package/src/composables/filePicker/usePickerSelection.ts +17 -4
- package/src/composables/filePicker/validation.ts +1 -0
- package/src/composables/useApi.ts +33 -4
- package/src/composables/useFilePicker.ts +41 -22
- package/src/composables/useGeneratedAnalysis.ts +10 -17
- package/src/composables/usePluginClient.ts +3 -0
- package/src/index.ts +3 -0
- package/src/styles/components/file-picker.css +6 -5
- package/src/types/filePicker.ts +9 -2
- package/dist/components-6IIxCSI8.js.map +0 -1
- package/dist/errors-_W0-LbA6.js.map +0 -1
- package/dist/useControlSchema-aMZlb4Sc.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: {
|
|
@@ -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,25 @@ 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
111
|
expect({ attempts: adapter.mock.calls.length, errorType: error?.constructor, token: authStore.token }).toEqual({
|
|
110
112
|
attempts: 1,
|
|
111
|
-
errorType: AuthenticationRequiredError,
|
|
113
|
+
errorType: typedErrors ? MintApiError : AuthenticationRequiredError,
|
|
112
114
|
token: null,
|
|
113
115
|
})
|
|
114
116
|
})
|
|
115
117
|
|
|
116
|
-
it('does not clear auth state for public requests that return 401', async () => {
|
|
118
|
+
it.each([false, true])('does not clear auth state for public requests that return 401 (typedErrors=%s)', async (typedErrors) => {
|
|
117
119
|
const authStore = useAuthStore()
|
|
118
120
|
authStore.setToken('still-valid-token', 3600)
|
|
119
|
-
const api = useApi({ baseUrl: '/api', withAuth: false })
|
|
121
|
+
const api = useApi({ baseUrl: '/api', typedErrors, withAuth: false })
|
|
120
122
|
|
|
121
123
|
const error = await api.get('/public', { adapter: unauthorizedAdapter }).catch((reason: unknown) => reason)
|
|
122
124
|
|
|
@@ -126,10 +128,10 @@ describe('useApi URL helpers', () => {
|
|
|
126
128
|
})
|
|
127
129
|
})
|
|
128
130
|
|
|
129
|
-
it('does not clear a replacement token after an older request returns 401', async () => {
|
|
131
|
+
it.each([false, true])('does not clear a replacement token after an older request returns 401 (typedErrors=%s)', async (typedErrors) => {
|
|
130
132
|
const authStore = useAuthStore()
|
|
131
133
|
authStore.setToken('rejected-token', 3600)
|
|
132
|
-
const api = useApi({ baseUrl: '/api' })
|
|
134
|
+
const api = useApi({ baseUrl: '/api', typedErrors })
|
|
133
135
|
|
|
134
136
|
await api.get('/protected', {
|
|
135
137
|
adapter: async (config) => {
|
|
@@ -141,9 +143,10 @@ describe('useApi URL helpers', () => {
|
|
|
141
143
|
expect(authStore.token).toBe('replacement-token')
|
|
142
144
|
})
|
|
143
145
|
|
|
144
|
-
it('does not clear a token created while an unauthenticated request is in flight', async () => {
|
|
146
|
+
it.each([false, true])('does not clear a token created while an unauthenticated request is in flight (typedErrors=%s)', async (typedErrors) => {
|
|
145
147
|
const authStore = useAuthStore()
|
|
146
|
-
const api = useApi({ baseUrl: '/api' })
|
|
148
|
+
const api = useApi({ baseUrl: '/api', typedErrors })
|
|
149
|
+
expect(authStore.token).toBeNull()
|
|
147
150
|
|
|
148
151
|
await api.get('/protected', {
|
|
149
152
|
adapter: async (config) => {
|
|
@@ -154,4 +157,74 @@ describe('useApi URL helpers', () => {
|
|
|
154
157
|
|
|
155
158
|
expect(authStore.token).toBe('new-login-token')
|
|
156
159
|
})
|
|
160
|
+
|
|
161
|
+
it('normalizes a typed response when typed errors are enabled', async () => {
|
|
162
|
+
const api = useApi({ baseUrl: '/api', typedErrors: true })
|
|
163
|
+
const adapter = vi.fn(async (config: AxiosRequestConfig) => Promise.reject(new AxiosError(
|
|
164
|
+
'Not Found',
|
|
165
|
+
AxiosError.ERR_BAD_REQUEST,
|
|
166
|
+
config as InternalAxiosRequestConfig,
|
|
167
|
+
undefined,
|
|
168
|
+
{
|
|
169
|
+
data: {
|
|
170
|
+
code: 'leaf.ARTIFACT_NOT_FOUND',
|
|
171
|
+
message: 'Artifact is not available',
|
|
172
|
+
status: 404,
|
|
173
|
+
request_id: 'req-generated',
|
|
174
|
+
details: { artifact_id: 67 },
|
|
175
|
+
detail: 'Artifact is not available',
|
|
176
|
+
},
|
|
177
|
+
status: 404,
|
|
178
|
+
statusText: 'Not Found',
|
|
179
|
+
headers: { 'x-request-id': 'req-generated' },
|
|
180
|
+
config: config as InternalAxiosRequestConfig,
|
|
181
|
+
},
|
|
182
|
+
)))
|
|
183
|
+
|
|
184
|
+
const error = await api.get('/artifacts/67', { adapter }).catch((reason: unknown) => reason)
|
|
185
|
+
|
|
186
|
+
const typedError = error as MintApiError
|
|
187
|
+
expect({ attempts: adapter.mock.calls.length, type: typedError.constructor, code: typedError.code }).toEqual({
|
|
188
|
+
attempts: 1,
|
|
189
|
+
type: MintApiError,
|
|
190
|
+
code: 'leaf.ARTIFACT_NOT_FOUND',
|
|
191
|
+
})
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
it.each([true, false])('normalizes Blob download errors (JSON=%s)', async (json) => {
|
|
195
|
+
const api = useApi({ typedErrors: true })
|
|
196
|
+
const body = { code: 'artifact.unavailable', message: 'No artifact', details: { id: 67 } }
|
|
197
|
+
const blob = new Blob([json ? JSON.stringify(body) : 'Bad gateway'], {
|
|
198
|
+
type: json ? 'application/json' : 'text/plain',
|
|
199
|
+
})
|
|
200
|
+
let cause: AxiosError | undefined
|
|
201
|
+
const error = await api.get('/download', {
|
|
202
|
+
responseType: 'blob',
|
|
203
|
+
adapter: async (config) => {
|
|
204
|
+
cause = new AxiosError('Bad gateway', AxiosError.ERR_BAD_RESPONSE, config, undefined, {
|
|
205
|
+
data: blob, status: 502, statusText: 'Bad gateway',
|
|
206
|
+
headers: { 'x-request-id': 'blob-request' }, config,
|
|
207
|
+
})
|
|
208
|
+
throw cause
|
|
209
|
+
},
|
|
210
|
+
}).catch((reason: unknown) => reason)
|
|
211
|
+
|
|
212
|
+
expect(error).toBeInstanceOf(MintApiError)
|
|
213
|
+
expect(error).toMatchObject({
|
|
214
|
+
code: json ? body.code : 'unknown',
|
|
215
|
+
message: json ? body.message : 'Bad gateway',
|
|
216
|
+
status: 502, requestId: 'blob-request', cause,
|
|
217
|
+
})
|
|
218
|
+
expect((error as MintApiError).body).toEqual(json ? body : blob)
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
it('leaves cancellations and network failures unchanged in typed mode', async () => {
|
|
222
|
+
const api = useApi({ typedErrors: true })
|
|
223
|
+
const cause = new AxiosError('Offline', AxiosError.ERR_NETWORK)
|
|
224
|
+
await expect(api.get('/offline', { adapter: async () => { throw cause } })).rejects.toBe(cause)
|
|
225
|
+
const controller = new AbortController()
|
|
226
|
+
controller.abort()
|
|
227
|
+
await expect(api.get('/cancelled', { signal: controller.signal })).rejects.toMatchObject({ code: 'ERR_CANCELED' })
|
|
228
|
+
})
|
|
229
|
+
|
|
157
230
|
})
|
|
@@ -2,6 +2,7 @@ import { effectScope } from 'vue'
|
|
|
2
2
|
import { describe, expect, it, vi } from 'vitest'
|
|
3
3
|
|
|
4
4
|
import { useGeneratedAnalysis } from '../../composables/useGeneratedAnalysis'
|
|
5
|
+
import { MintApiError } from '../../apiErrors'
|
|
5
6
|
import {
|
|
6
7
|
createGeneratedAnalysisTransport,
|
|
7
8
|
type GeneratedAnalysisTransport,
|
|
@@ -701,7 +702,10 @@ describe('useGeneratedAnalysis', () => {
|
|
|
701
702
|
])
|
|
702
703
|
})
|
|
703
704
|
|
|
704
|
-
it(
|
|
705
|
+
it.each([
|
|
706
|
+
{ detail: 'Session not found' },
|
|
707
|
+
{ code: 'resource.not_found', message: 'Session not found', request_id: 'session-request' },
|
|
708
|
+
])('should recreate an expired session and retry one job with the same request id (%j)', async (errorBody) => {
|
|
705
709
|
const storage = memoryStorage()
|
|
706
710
|
const createId = vi.fn()
|
|
707
711
|
.mockReturnValueOnce('tab-1')
|
|
@@ -713,7 +717,7 @@ describe('useGeneratedAnalysis', () => {
|
|
|
713
717
|
id: 'session-1', tab_id: 'tab-1', created_at: '', last_seen_at: '', active_job_ids: [],
|
|
714
718
|
}))
|
|
715
719
|
.mockResolvedValueOnce(response({ jobs: [] }))
|
|
716
|
-
.mockResolvedValueOnce(response(
|
|
720
|
+
.mockResolvedValueOnce(response(errorBody, 404))
|
|
717
721
|
.mockResolvedValueOnce(response({ sessions: [] }))
|
|
718
722
|
.mockResolvedValueOnce(response({
|
|
719
723
|
id: 'session-2', tab_id: 'tab-1', created_at: '', last_seen_at: '', active_job_ids: [],
|
|
@@ -796,6 +800,7 @@ describe('useGeneratedAnalysis', () => {
|
|
|
796
800
|
scope.stop()
|
|
797
801
|
}
|
|
798
802
|
|
|
803
|
+
expect(caught).toBeInstanceOf(MintApiError)
|
|
799
804
|
expect({
|
|
800
805
|
status: (caught as { status?: number } | undefined)?.status,
|
|
801
806
|
message: caught instanceof Error ? caught.message : null,
|
|
@@ -184,6 +184,7 @@ describe('usePluginClient', () => {
|
|
|
184
184
|
|
|
185
185
|
expect(requestConfigs[0]!.baseURL).toBe('/api/drp')
|
|
186
186
|
expect(requestConfigs[0]!.url).toBe('/health')
|
|
187
|
+
expect(requestConfigs[0]).toMatchObject({ _mintTypedErrors: true })
|
|
187
188
|
})
|
|
188
189
|
|
|
189
190
|
it('omits MINT auth transport for a public generated endpoint', async () => {
|
|
@@ -396,6 +397,7 @@ describe('usePluginClient', () => {
|
|
|
396
397
|
expect(requestConfigs[0]!.url).toBe('/schedule/import')
|
|
397
398
|
expect(requestConfigs[0]!.params).toEqual({ instrument_id: 'orbitrap', mode: 'replace' })
|
|
398
399
|
expect(requestConfigs[0]!.headers).toEqual({ 'Content-Type': undefined })
|
|
400
|
+
expect(requestConfigs[0]).toMatchObject({ _mintTypedErrors: true })
|
|
399
401
|
expect(requestBodies[0]).toBeInstanceOf(FormData)
|
|
400
402
|
const form = requestBodies[0] as FormData
|
|
401
403
|
expect(form.get('file')).toBeInstanceOf(Blob)
|
|
@@ -462,6 +464,7 @@ describe('usePluginClient', () => {
|
|
|
462
464
|
expect(requestConfigs[0]!.url).toBe('/schedule/export')
|
|
463
465
|
expect(requestConfigs[0]!.params).toEqual({ instrument_id: 'orbitrap', start: '2026-05-27' })
|
|
464
466
|
expect(requestConfigs[0]!.responseType).toBe('blob')
|
|
467
|
+
expect(requestConfigs[0]).toMatchObject({ _mintTypedErrors: true })
|
|
465
468
|
})
|
|
466
469
|
|
|
467
470
|
it('downloads generated POST endpoint blobs with the contract body and request options', async () => {
|
package/src/apiErrors.ts
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
export interface MintApiErrorEnvelope {
|
|
2
|
+
code: string
|
|
3
|
+
message: string
|
|
4
|
+
status: number
|
|
5
|
+
request_id: string | null
|
|
6
|
+
details: unknown
|
|
7
|
+
detail?: unknown
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const STATUS_MESSAGES: Readonly<Record<number, string>> = {
|
|
11
|
+
400: 'Bad request',
|
|
12
|
+
401: 'Authentication required',
|
|
13
|
+
403: 'Permission denied',
|
|
14
|
+
404: 'Resource not found',
|
|
15
|
+
405: 'Method not allowed',
|
|
16
|
+
409: 'Conflict',
|
|
17
|
+
413: 'Payload too large',
|
|
18
|
+
415: 'Unsupported media type',
|
|
19
|
+
422: 'Validation error',
|
|
20
|
+
429: 'Rate limit exceeded',
|
|
21
|
+
500: 'Internal server error',
|
|
22
|
+
502: 'Bad gateway',
|
|
23
|
+
503: 'Service unavailable',
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function asRecord(value: unknown): Record<string, unknown> | null {
|
|
27
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return null
|
|
28
|
+
return value as Record<string, unknown>
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Public error thrown by typed MINT SDK requests. */
|
|
32
|
+
export class MintApiError extends Error {
|
|
33
|
+
readonly name = 'MintApiError'
|
|
34
|
+
|
|
35
|
+
constructor(
|
|
36
|
+
message: string,
|
|
37
|
+
readonly code: string,
|
|
38
|
+
readonly status: number,
|
|
39
|
+
readonly requestId: string | null,
|
|
40
|
+
readonly details: unknown,
|
|
41
|
+
readonly detail: unknown,
|
|
42
|
+
readonly body: unknown,
|
|
43
|
+
readonly cause?: unknown,
|
|
44
|
+
) {
|
|
45
|
+
super(message)
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface MintApiErrorOptions {
|
|
50
|
+
requestId?: string | null
|
|
51
|
+
cause?: unknown
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Normalize current typed envelopes and the previous FastAPI detail shape. */
|
|
55
|
+
export function mintApiErrorFromResponse(
|
|
56
|
+
status: number,
|
|
57
|
+
payload: unknown,
|
|
58
|
+
options: MintApiErrorOptions = {},
|
|
59
|
+
): MintApiError {
|
|
60
|
+
const body = asRecord(payload)
|
|
61
|
+
const legacyDetail = body?.detail
|
|
62
|
+
const inner = asRecord(legacyDetail) ?? body
|
|
63
|
+
const text = (value: unknown): string | undefined =>
|
|
64
|
+
typeof value === 'string' && value ? value : undefined
|
|
65
|
+
// Complete envelopes own the public code; older wrappers defer to the plugin payload.
|
|
66
|
+
const canonical = typeof body?.status === 'number' && text(body.code) && text(body.message)
|
|
67
|
+
const code = (canonical ? text(body.code) : text(inner?.error) ?? text(inner?.code))
|
|
68
|
+
?? text(body?.code) ?? 'unknown'
|
|
69
|
+
const message = (canonical ? text(body.message) : text(inner?.message))
|
|
70
|
+
?? text(body?.message) ?? text(legacyDetail)
|
|
71
|
+
?? STATUS_MESSAGES[status] ?? `HTTP ${status}`
|
|
72
|
+
const details = body?.details ?? inner?.details ?? null
|
|
73
|
+
const requestId = text(body?.request_id) ?? text(inner?.request_id) ?? options.requestId ?? null
|
|
74
|
+
|
|
75
|
+
return new MintApiError(
|
|
76
|
+
message,
|
|
77
|
+
code,
|
|
78
|
+
status,
|
|
79
|
+
requestId,
|
|
80
|
+
details,
|
|
81
|
+
legacyDetail,
|
|
82
|
+
payload,
|
|
83
|
+
options.cause,
|
|
84
|
+
)
|
|
85
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { ref } from 'vue'
|
|
3
|
+
import BaseButton from './BaseButton.vue'
|
|
4
|
+
import FilePicker from './FilePicker.vue'
|
|
5
|
+
import type { PickerAdapter, PickerNode } from '../types/filePicker'
|
|
6
|
+
|
|
7
|
+
const open = ref(true)
|
|
8
|
+
const hideRestricted = ref(true)
|
|
9
|
+
const nodes: PickerNode[] = [
|
|
10
|
+
{ kind: 'folder', path: 'shared', name: 'Shared data' },
|
|
11
|
+
{ kind: 'folder', path: 'restricted', name: 'Restricted data' },
|
|
12
|
+
{ kind: 'file', path: 'shared/run.raw', parentPath: 'shared', name: 'run.raw', format: 'raw', bytes: 4096 },
|
|
13
|
+
{ kind: 'file', path: 'restricted/private.raw', parentPath: 'restricted', name: 'private.raw', format: 'raw', bytes: 2048 },
|
|
14
|
+
]
|
|
15
|
+
const adapter: PickerAdapter = {
|
|
16
|
+
listRoot: async () => nodes.filter((node) => !node.parentPath),
|
|
17
|
+
listChildren: async (path) => nodes.filter((node) => node.parentPath === path),
|
|
18
|
+
search: async (query, scope) => nodes.filter((node) =>
|
|
19
|
+
(scope.recursive ? !scope.path || node.path.startsWith(`${scope.path}/`) : (node.parentPath ?? '') === scope.path)
|
|
20
|
+
&& node.name.toLowerCase().includes(query.toLowerCase()),
|
|
21
|
+
),
|
|
22
|
+
}
|
|
23
|
+
// The host owns this rule; its server must also enforce access independently.
|
|
24
|
+
const systemFilter = (node: PickerNode) => !hideRestricted.value || !node.path.startsWith('restricted')
|
|
25
|
+
</script>
|
|
26
|
+
|
|
27
|
+
<template>
|
|
28
|
+
<Story title="Forms/FilePicker">
|
|
29
|
+
<Variant title="Local upload and system filter">
|
|
30
|
+
<div style="padding: 2rem; max-width: 600px;">
|
|
31
|
+
<BaseButton @click="open = true">Choose data</BaseButton>
|
|
32
|
+
<FilePicker
|
|
33
|
+
v-model:open="open"
|
|
34
|
+
:adapter="adapter"
|
|
35
|
+
:sources="['localFile', 'server']"
|
|
36
|
+
:system-filter="systemFilter"
|
|
37
|
+
/>
|
|
38
|
+
</div>
|
|
39
|
+
<template #controls>
|
|
40
|
+
<HstCheckbox v-model="hideRestricted" title="Hide restricted server data" />
|
|
41
|
+
</template>
|
|
42
|
+
</Variant>
|
|
43
|
+
</Story>
|
|
44
|
+
</template>
|