@morscherlab/mld-sdk 0.10.0 → 0.10.1
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/__stories__/experiment-helpers.d.ts +25 -0
- package/dist/__tests__/composables/usePlatformContext.test.d.ts +1 -0
- package/dist/components/ExperimentPopover.vue.d.ts +4 -0
- package/dist/components/ExperimentPopover.vue.js +138 -112
- package/dist/components/ExperimentPopover.vue.js.map +1 -1
- package/dist/components/FitPanel.vue.d.ts +1 -1
- package/dist/composables/useAuth.js +6 -1
- package/dist/composables/useAuth.js.map +1 -1
- package/dist/composables/usePlatformContext.js +60 -20
- package/dist/composables/usePlatformContext.js.map +1 -1
- package/dist/styles.css +270 -121
- package/package.json +1 -1
- package/src/__stories__/experiment-helpers.ts +78 -0
- package/src/__tests__/composables/useAuth.test.ts +47 -2
- package/src/__tests__/composables/usePlatformContext.test.ts +116 -0
- package/src/components/AppLayout.story.vue +74 -0
- package/src/components/AppTopBar.story.vue +29 -0
- package/src/components/ExperimentPopover.vue +90 -58
- package/src/composables/useAuth.ts +7 -1
- package/src/composables/usePlatformContext.ts +74 -24
- package/src/styles/components/experiment-popover.css +86 -3
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared helpers for story files that need experiment selector integration.
|
|
3
|
+
* Provides mock data, API interceptors, and a provider wrapper component.
|
|
4
|
+
*/
|
|
5
|
+
import { defineComponent, onUnmounted } from 'vue'
|
|
6
|
+
import { useAppExperiment } from '../composables/useAppExperiment'
|
|
7
|
+
import { useApi } from '../composables/useApi'
|
|
8
|
+
import type { ExperimentSummary } from '../types'
|
|
9
|
+
|
|
10
|
+
export const mockExperiments: ExperimentSummary[] = [
|
|
11
|
+
{ id: 1, experiment_code: 'DR-HeLa-DOX-001', name: 'Doxorubicin dose-response in HeLa cells', status: 'completed', experiment_type: 'dose_response', project: 'Oncology Screen Q1', project_name: 'Oncology Screen Q1', created_at: '2026-02-28T14:30:00Z', updated_at: '2026-03-01T09:15:00Z', has_design_data: true },
|
|
12
|
+
{ id: 2, experiment_code: 'CV-MCF7-24H-002', name: 'MCF-7 cell viability 24h time course', status: 'completed', experiment_type: 'cell_viability', project: 'Oncology Screen Q1', project_name: 'Oncology Screen Q1', created_at: '2026-02-25T10:00:00Z', updated_at: '2026-02-26T16:45:00Z', has_design_data: true },
|
|
13
|
+
{ id: 3, experiment_code: 'DR-PANC1-GEM-003', name: 'Gemcitabine IC50 in PANC-1', status: 'ongoing', experiment_type: 'dose_response', project: 'Pancreatic Cancer', project_name: 'Pancreatic Cancer', created_at: '2026-03-01T08:00:00Z', updated_at: '2026-03-02T11:30:00Z', has_design_data: true },
|
|
14
|
+
{ id: 4, experiment_code: 'CV-A549-CIS-004', name: 'Cisplatin cytotoxicity A549 lung', status: 'ongoing', experiment_type: 'cell_viability', project: 'Lung Cancer Panel', project_name: 'Lung Cancer Panel', created_at: '2026-03-01T13:00:00Z', updated_at: '2026-03-02T08:00:00Z', has_design_data: false },
|
|
15
|
+
{ id: 5, experiment_code: 'DR-HEK293-CTRL-005', name: 'HEK-293T control viability baseline', status: 'completed', experiment_type: 'dose_response', project: 'Toxicity Controls', project_name: 'Toxicity Controls', created_at: '2026-02-20T09:00:00Z', updated_at: '2026-02-22T17:00:00Z', has_design_data: true },
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
const mockExperimentTypes = [
|
|
19
|
+
{ name: 'dose_response', color: '#3b82f6' },
|
|
20
|
+
{ name: 'cell_viability', color: '#10b981' },
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
const mockProjects = [
|
|
24
|
+
{ id: 1, name: 'Oncology Screen Q1' },
|
|
25
|
+
{ id: 2, name: 'Pancreatic Cancer' },
|
|
26
|
+
{ id: 3, name: 'Lung Cancer Panel' },
|
|
27
|
+
{ id: 4, name: 'Toxicity Controls' },
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
export function installMockInterceptor() {
|
|
31
|
+
const { client } = useApi()
|
|
32
|
+
client.interceptors.request.use((config) => {
|
|
33
|
+
const url = config.url ?? ''
|
|
34
|
+
if (url.includes('/experiments/experiment-types')) {
|
|
35
|
+
config.adapter = () => Promise.resolve({ data: mockExperimentTypes, status: 200, statusText: 'OK', headers: {}, config })
|
|
36
|
+
return config
|
|
37
|
+
}
|
|
38
|
+
if (url.includes('/projects')) {
|
|
39
|
+
config.adapter = () => Promise.resolve({ data: mockProjects, status: 200, statusText: 'OK', headers: {}, config })
|
|
40
|
+
return config
|
|
41
|
+
}
|
|
42
|
+
if (url.startsWith('/api/experiments')) {
|
|
43
|
+
const params = new URLSearchParams(url.split('?')[1] ?? '')
|
|
44
|
+
const skip = Number(params.get('skip') ?? 0)
|
|
45
|
+
const limit = Number(params.get('limit') ?? 100)
|
|
46
|
+
let filtered = [...mockExperiments]
|
|
47
|
+
const status = params.get('status')
|
|
48
|
+
const search = params.get('search')?.toLowerCase()
|
|
49
|
+
if (status) filtered = filtered.filter(e => e.status === status)
|
|
50
|
+
if (search) filtered = filtered.filter(e => e.name.toLowerCase().includes(search) || e.experiment_code?.toLowerCase().includes(search))
|
|
51
|
+
config.adapter = () => Promise.resolve({ data: { experiments: filtered.slice(skip, skip + limit), total: filtered.length }, status: 200, statusText: 'OK', headers: {}, config })
|
|
52
|
+
}
|
|
53
|
+
return config
|
|
54
|
+
})
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Wrapper component that fakes integrated mode and provides useAppExperiment context.
|
|
59
|
+
* Use in story files to demonstrate experiment selector integration.
|
|
60
|
+
*
|
|
61
|
+
* Props:
|
|
62
|
+
* preselect (boolean, default true) - pre-select the first mock experiment
|
|
63
|
+
*/
|
|
64
|
+
export const ExperimentProvider = defineComponent({
|
|
65
|
+
props: { preselect: { type: Boolean, default: true } },
|
|
66
|
+
setup(props, { slots }) {
|
|
67
|
+
;(window as any).__MLD_PLATFORM__ = { isIntegrated: true, theme: 'system' }
|
|
68
|
+
installMockInterceptor()
|
|
69
|
+
const { set } = useAppExperiment({
|
|
70
|
+
onSave: async () => 'Saved!',
|
|
71
|
+
})
|
|
72
|
+
if (props.preselect) {
|
|
73
|
+
set({ id: 1, name: 'Doxorubicin dose-response in HeLa cells', status: 'completed' })
|
|
74
|
+
}
|
|
75
|
+
onUnmounted(() => { delete (window as any).__MLD_PLATFORM__ })
|
|
76
|
+
return () => slots.default?.()
|
|
77
|
+
},
|
|
78
|
+
})
|
|
@@ -5,13 +5,18 @@ import axios from 'axios'
|
|
|
5
5
|
vi.mock('axios')
|
|
6
6
|
const mockedAxios = vi.mocked(axios, true)
|
|
7
7
|
|
|
8
|
+
const unmountedCallbacks: Array<() => void> = []
|
|
9
|
+
|
|
8
10
|
// Mock vue lifecycle hooks since we're not in a component
|
|
9
11
|
vi.mock('vue', async () => {
|
|
10
|
-
const actual = await vi.importActual('vue')
|
|
12
|
+
const actual = await vi.importActual<typeof import('vue')>('vue')
|
|
11
13
|
return {
|
|
12
14
|
...actual,
|
|
15
|
+
getCurrentInstance: vi.fn(() => ({})),
|
|
13
16
|
onMounted: vi.fn((cb: () => void) => cb()),
|
|
14
|
-
onUnmounted: vi.fn()
|
|
17
|
+
onUnmounted: vi.fn((cb: () => void) => {
|
|
18
|
+
unmountedCallbacks.push(cb)
|
|
19
|
+
}),
|
|
15
20
|
}
|
|
16
21
|
})
|
|
17
22
|
|
|
@@ -23,6 +28,7 @@ describe('useAuth', () => {
|
|
|
23
28
|
beforeEach(() => {
|
|
24
29
|
setActivePinia(createPinia())
|
|
25
30
|
vi.useFakeTimers()
|
|
31
|
+
unmountedCallbacks.length = 0
|
|
26
32
|
|
|
27
33
|
// Mock settings store
|
|
28
34
|
const settingsStore = useSettingsStore()
|
|
@@ -118,6 +124,45 @@ describe('useAuth', () => {
|
|
|
118
124
|
})
|
|
119
125
|
})
|
|
120
126
|
|
|
127
|
+
describe('refreshToken - lifecycle behavior', () => {
|
|
128
|
+
it('keeps scheduled refresh when one of multiple consumers unmounts', async () => {
|
|
129
|
+
mockedAxios.post.mockImplementation((url: string) => {
|
|
130
|
+
if (url.endsWith('/auth/login')) {
|
|
131
|
+
return Promise.resolve({
|
|
132
|
+
data: { access_token: 'test-token', expires_in: 301, token_type: 'bearer' },
|
|
133
|
+
})
|
|
134
|
+
}
|
|
135
|
+
if (url.endsWith('/auth/refresh')) {
|
|
136
|
+
return Promise.resolve({
|
|
137
|
+
data: { access_token: 'refreshed-token', expires_in: 3600, token_type: 'bearer' },
|
|
138
|
+
})
|
|
139
|
+
}
|
|
140
|
+
throw new Error(`Unexpected POST ${url}`)
|
|
141
|
+
})
|
|
142
|
+
mockedAxios.get.mockResolvedValue({
|
|
143
|
+
data: { id: '1', username: 'test', shortname: null, email: null, role: 'user', is_active: true },
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
const authA = useAuth()
|
|
147
|
+
useAuth()
|
|
148
|
+
|
|
149
|
+
await authA.login('test', 'password')
|
|
150
|
+
|
|
151
|
+
expect(unmountedCallbacks).toHaveLength(2)
|
|
152
|
+
unmountedCallbacks[0]!()
|
|
153
|
+
|
|
154
|
+
vi.advanceTimersByTime(2_000)
|
|
155
|
+
await Promise.resolve()
|
|
156
|
+
|
|
157
|
+
const refreshCalls = mockedAxios.post.mock.calls.filter(
|
|
158
|
+
([url]) => typeof url === 'string' && url.endsWith('/auth/refresh'),
|
|
159
|
+
)
|
|
160
|
+
expect(refreshCalls).toHaveLength(1)
|
|
161
|
+
|
|
162
|
+
unmountedCallbacks[1]!()
|
|
163
|
+
})
|
|
164
|
+
})
|
|
165
|
+
|
|
121
166
|
describe('verifyToken', () => {
|
|
122
167
|
it('should return false when no token', async () => {
|
|
123
168
|
const { verifyToken } = useAuth()
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest'
|
|
2
|
+
|
|
3
|
+
const unmountedCallbacks: Array<() => void> = []
|
|
4
|
+
|
|
5
|
+
type MessageHandler = (event: MessageEvent) => void
|
|
6
|
+
let messageHandler: MessageHandler | null = null
|
|
7
|
+
|
|
8
|
+
vi.mock('vue', async () => {
|
|
9
|
+
const actual = await vi.importActual<typeof import('vue')>('vue')
|
|
10
|
+
return {
|
|
11
|
+
...actual,
|
|
12
|
+
onMounted: vi.fn((cb: () => void) => cb()),
|
|
13
|
+
onUnmounted: vi.fn((cb: () => void) => {
|
|
14
|
+
unmountedCallbacks.push(cb)
|
|
15
|
+
}),
|
|
16
|
+
}
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
import { usePlatformContext } from '../../composables/usePlatformContext'
|
|
20
|
+
|
|
21
|
+
function sendThemeMessage(origin: string, theme: 'light' | 'dark' | 'system'): void {
|
|
22
|
+
if (!messageHandler) {
|
|
23
|
+
throw new Error('Message handler was not registered')
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
messageHandler({
|
|
27
|
+
source: window.parent,
|
|
28
|
+
origin,
|
|
29
|
+
data: {
|
|
30
|
+
type: 'mld:theme-changed',
|
|
31
|
+
payload: theme,
|
|
32
|
+
},
|
|
33
|
+
} as MessageEvent)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
describe('usePlatformContext', () => {
|
|
37
|
+
beforeEach(() => {
|
|
38
|
+
unmountedCallbacks.length = 0
|
|
39
|
+
messageHandler = null
|
|
40
|
+
|
|
41
|
+
vi.spyOn(window, 'addEventListener').mockImplementation((type, listener) => {
|
|
42
|
+
if (type === 'message') {
|
|
43
|
+
messageHandler = listener as MessageHandler
|
|
44
|
+
}
|
|
45
|
+
})
|
|
46
|
+
vi.spyOn(window, 'removeEventListener').mockImplementation(() => {})
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
afterEach(() => {
|
|
50
|
+
while (unmountedCallbacks.length > 0) {
|
|
51
|
+
const callback = unmountedCallbacks.pop()
|
|
52
|
+
callback?.()
|
|
53
|
+
}
|
|
54
|
+
vi.restoreAllMocks()
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('clears allowAnyOrigin policy after last consumer unmounts', () => {
|
|
58
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
59
|
+
|
|
60
|
+
const first = usePlatformContext({ allowAnyOrigin: true })
|
|
61
|
+
sendThemeMessage('https://evil.example', 'dark')
|
|
62
|
+
expect(first.theme.value).toBe('dark')
|
|
63
|
+
|
|
64
|
+
expect(unmountedCallbacks).toHaveLength(1)
|
|
65
|
+
unmountedCallbacks[0]!()
|
|
66
|
+
unmountedCallbacks.length = 0
|
|
67
|
+
|
|
68
|
+
const second = usePlatformContext()
|
|
69
|
+
sendThemeMessage('https://evil.example', 'light')
|
|
70
|
+
expect(second.theme.value).toBe('system')
|
|
71
|
+
expect(
|
|
72
|
+
warnSpy.mock.calls.some(([msg]) => String(msg).includes('Rejected postMessage')),
|
|
73
|
+
).toBe(true)
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('does not leak explicit allowed origins to new consumers', () => {
|
|
77
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
78
|
+
|
|
79
|
+
const first = usePlatformContext({ allowedOrigins: ['https://trusted.example'] })
|
|
80
|
+
sendThemeMessage('https://trusted.example', 'dark')
|
|
81
|
+
expect(first.theme.value).toBe('dark')
|
|
82
|
+
|
|
83
|
+
expect(unmountedCallbacks).toHaveLength(1)
|
|
84
|
+
unmountedCallbacks[0]!()
|
|
85
|
+
unmountedCallbacks.length = 0
|
|
86
|
+
|
|
87
|
+
const second = usePlatformContext()
|
|
88
|
+
sendThemeMessage('https://trusted.example', 'light')
|
|
89
|
+
expect(second.theme.value).toBe('system')
|
|
90
|
+
expect(
|
|
91
|
+
warnSpy.mock.calls.some(([msg]) => String(msg).includes('Rejected postMessage')),
|
|
92
|
+
).toBe(true)
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('removes allowAnyOrigin scope when that consumer unmounts while others remain', () => {
|
|
96
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
97
|
+
|
|
98
|
+
usePlatformContext({ allowAnyOrigin: true })
|
|
99
|
+
const second = usePlatformContext()
|
|
100
|
+
|
|
101
|
+
sendThemeMessage('https://evil.example', 'dark')
|
|
102
|
+
expect(second.theme.value).toBe('dark')
|
|
103
|
+
|
|
104
|
+
expect(unmountedCallbacks).toHaveLength(2)
|
|
105
|
+
unmountedCallbacks[0]!()
|
|
106
|
+
|
|
107
|
+
sendThemeMessage('https://evil.example', 'light')
|
|
108
|
+
expect(second.theme.value).toBe('dark')
|
|
109
|
+
expect(
|
|
110
|
+
warnSpy.mock.calls.some(([msg]) => String(msg).includes('Rejected postMessage')),
|
|
111
|
+
).toBe(true)
|
|
112
|
+
|
|
113
|
+
unmountedCallbacks[1]!()
|
|
114
|
+
unmountedCallbacks.length = 0
|
|
115
|
+
})
|
|
116
|
+
})
|
|
@@ -7,6 +7,7 @@ import BaseSlider from './BaseSlider.vue'
|
|
|
7
7
|
import BaseSelect from './BaseSelect.vue'
|
|
8
8
|
import BaseToggle from './BaseToggle.vue'
|
|
9
9
|
import BaseButton from './BaseButton.vue'
|
|
10
|
+
import { ExperimentProvider } from '../__stories__/experiment-helpers'
|
|
10
11
|
import type { SidebarToolSection, TopBarTab } from '../types'
|
|
11
12
|
|
|
12
13
|
const activeTab = ref('analysis')
|
|
@@ -214,5 +215,78 @@ const logScale = ref(false)
|
|
|
214
215
|
</AppLayout>
|
|
215
216
|
</div>
|
|
216
217
|
</Variant>
|
|
218
|
+
|
|
219
|
+
<Variant title="With Experiment Selector">
|
|
220
|
+
<ExperimentProvider>
|
|
221
|
+
<div style="height: 600px;">
|
|
222
|
+
<AppLayout :floating="true" sidebar-width="280px">
|
|
223
|
+
<template #topbar>
|
|
224
|
+
<AppTopBar
|
|
225
|
+
plugin-name="IC50 Calculator"
|
|
226
|
+
title="Analysis"
|
|
227
|
+
:tabs="tabs"
|
|
228
|
+
:current-tab-id="activeTab"
|
|
229
|
+
:show-theme-toggle="true"
|
|
230
|
+
home-path=""
|
|
231
|
+
@tab-select="t => activeTab = t.id"
|
|
232
|
+
/>
|
|
233
|
+
</template>
|
|
234
|
+
<template #sidebar>
|
|
235
|
+
<AppSidebar
|
|
236
|
+
:panels="toolPanels"
|
|
237
|
+
:active-view="activeTab"
|
|
238
|
+
:floating="false"
|
|
239
|
+
>
|
|
240
|
+
<template #section-parameters>
|
|
241
|
+
<BaseSlider v-model="threshold" label="Threshold" :min="0" :max="100" />
|
|
242
|
+
<BaseSelect v-model="method" label="Method" :options="methods" />
|
|
243
|
+
</template>
|
|
244
|
+
<template #section-filters>
|
|
245
|
+
<BaseToggle v-model="showOutliers" label="Exclude outliers" />
|
|
246
|
+
</template>
|
|
247
|
+
<template #section-display>
|
|
248
|
+
<BaseToggle v-model="showOutliers" label="Show outliers" />
|
|
249
|
+
<BaseToggle v-model="logScale" label="Log scale" />
|
|
250
|
+
</template>
|
|
251
|
+
<template #section-export>
|
|
252
|
+
<BaseButton size="sm" variant="secondary">Export CSV</BaseButton>
|
|
253
|
+
</template>
|
|
254
|
+
</AppSidebar>
|
|
255
|
+
</template>
|
|
256
|
+
<div style="padding: 2rem;">
|
|
257
|
+
<h2 style="margin: 0 0 1rem; color: var(--text-primary, #1e293b);">
|
|
258
|
+
{{ activeTab.charAt(0).toUpperCase() + activeTab.slice(1) }}
|
|
259
|
+
</h2>
|
|
260
|
+
<p style="color: var(--text-muted, #94a3b8);">
|
|
261
|
+
Full layout with experiment selector in the top bar.
|
|
262
|
+
Click the flask icon to open the experiment popover and selector modal.
|
|
263
|
+
</p>
|
|
264
|
+
</div>
|
|
265
|
+
</AppLayout>
|
|
266
|
+
</div>
|
|
267
|
+
</ExperimentProvider>
|
|
268
|
+
</Variant>
|
|
269
|
+
|
|
270
|
+
<Variant title="With Experiment Selector (No Selection)">
|
|
271
|
+
<ExperimentProvider :preselect="false">
|
|
272
|
+
<div style="height: 600px;">
|
|
273
|
+
<AppLayout :floating="true">
|
|
274
|
+
<template #topbar>
|
|
275
|
+
<AppTopBar
|
|
276
|
+
plugin-name="Plate Analyzer"
|
|
277
|
+
title="Dashboard"
|
|
278
|
+
:show-theme-toggle="true"
|
|
279
|
+
home-path=""
|
|
280
|
+
/>
|
|
281
|
+
</template>
|
|
282
|
+
<div style="padding: 2rem;">
|
|
283
|
+
<p style="color: var(--text-muted, #94a3b8);">
|
|
284
|
+
No experiment selected yet. Click the flask icon in the top bar to select one.
|
|
285
|
+
</p>
|
|
286
|
+
</div>
|
|
287
|
+
</AppLayout>
|
|
288
|
+
</div>
|
|
289
|
+
</ExperimentProvider>
|
|
290
|
+
</Variant>
|
|
217
291
|
</Story>
|
|
218
292
|
</template>
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import AppTopBar from './AppTopBar.vue'
|
|
3
|
+
import { ExperimentProvider } from '../__stories__/experiment-helpers'
|
|
3
4
|
import type { TopBarVariant, TopBarPage, TopBarTab } from '../types'
|
|
4
5
|
|
|
5
6
|
const variants: TopBarVariant[] = ['card', 'default']
|
|
@@ -108,5 +109,33 @@ const sampleTabs: TopBarTab[] = [
|
|
|
108
109
|
<AppTopBar :show-logo="true" home-path="/" />
|
|
109
110
|
</div>
|
|
110
111
|
</Variant>
|
|
112
|
+
|
|
113
|
+
<Variant title="With Experiment Selector">
|
|
114
|
+
<div style="padding: 2rem;">
|
|
115
|
+
<ExperimentProvider>
|
|
116
|
+
<AppTopBar
|
|
117
|
+
plugin-name="IC50 Calculator"
|
|
118
|
+
title="Analysis"
|
|
119
|
+
variant="card"
|
|
120
|
+
:show-theme-toggle="true"
|
|
121
|
+
home-path=""
|
|
122
|
+
/>
|
|
123
|
+
</ExperimentProvider>
|
|
124
|
+
</div>
|
|
125
|
+
</Variant>
|
|
126
|
+
|
|
127
|
+
<Variant title="With Experiment Selector (Empty)">
|
|
128
|
+
<div style="padding: 2rem;">
|
|
129
|
+
<ExperimentProvider :preselect="false">
|
|
130
|
+
<AppTopBar
|
|
131
|
+
plugin-name="Plate Analyzer"
|
|
132
|
+
title="Dashboard"
|
|
133
|
+
variant="card"
|
|
134
|
+
:show-theme-toggle="true"
|
|
135
|
+
home-path=""
|
|
136
|
+
/>
|
|
137
|
+
</ExperimentProvider>
|
|
138
|
+
</div>
|
|
139
|
+
</Variant>
|
|
111
140
|
</Story>
|
|
112
141
|
</template>
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import { ref, watch, onMounted, onUnmounted } from 'vue'
|
|
3
|
+
import ConfirmDialog from './ConfirmDialog.vue'
|
|
3
4
|
|
|
4
5
|
interface Props {
|
|
5
6
|
experimentName?: string
|
|
@@ -10,6 +11,9 @@ interface Props {
|
|
|
10
11
|
saveLoading?: boolean
|
|
11
12
|
saveSuccessMessage?: string
|
|
12
13
|
saveDisabledMessage?: string
|
|
14
|
+
confirmSave?: boolean
|
|
15
|
+
confirmTitle?: string
|
|
16
|
+
confirmMessage?: string
|
|
13
17
|
}
|
|
14
18
|
|
|
15
19
|
const props = withDefaults(defineProps<Props>(), {
|
|
@@ -17,6 +21,7 @@ const props = withDefaults(defineProps<Props>(), {
|
|
|
17
21
|
showDetach: false,
|
|
18
22
|
saveDisabled: false,
|
|
19
23
|
saveLoading: false,
|
|
24
|
+
confirmSave: true,
|
|
20
25
|
})
|
|
21
26
|
|
|
22
27
|
const emit = defineEmits<{
|
|
@@ -28,6 +33,7 @@ const emit = defineEmits<{
|
|
|
28
33
|
const isOpen = ref(false)
|
|
29
34
|
const popoverRef = ref<HTMLElement | null>(null)
|
|
30
35
|
const showSuccess = ref(false)
|
|
36
|
+
const showConfirm = ref(false)
|
|
31
37
|
|
|
32
38
|
function toggle() {
|
|
33
39
|
isOpen.value = !isOpen.value
|
|
@@ -44,6 +50,15 @@ function handleSelect() {
|
|
|
44
50
|
|
|
45
51
|
function handleSave() {
|
|
46
52
|
if (props.saveDisabled || props.saveLoading) return
|
|
53
|
+
if (props.confirmSave) {
|
|
54
|
+
showConfirm.value = true
|
|
55
|
+
} else {
|
|
56
|
+
emit('save')
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function handleConfirmSave() {
|
|
61
|
+
showConfirm.value = false
|
|
47
62
|
emit('save')
|
|
48
63
|
}
|
|
49
64
|
|
|
@@ -58,12 +73,16 @@ function handleClickOutside(event: MouseEvent) {
|
|
|
58
73
|
}
|
|
59
74
|
}
|
|
60
75
|
|
|
76
|
+
let successTimer: ReturnType<typeof setTimeout> | null = null
|
|
77
|
+
|
|
61
78
|
// Show success state when saveSuccessMessage changes from empty to a value
|
|
62
79
|
watch(() => props.saveSuccessMessage, (msg) => {
|
|
80
|
+
if (successTimer) clearTimeout(successTimer)
|
|
63
81
|
if (msg) {
|
|
64
82
|
showSuccess.value = true
|
|
65
|
-
setTimeout(() => {
|
|
83
|
+
successTimer = setTimeout(() => {
|
|
66
84
|
showSuccess.value = false
|
|
85
|
+
successTimer = null
|
|
67
86
|
}, 3000)
|
|
68
87
|
}
|
|
69
88
|
})
|
|
@@ -74,6 +93,7 @@ onMounted(() => {
|
|
|
74
93
|
|
|
75
94
|
onUnmounted(() => {
|
|
76
95
|
document.removeEventListener('click', handleClickOutside)
|
|
96
|
+
if (successTimer) clearTimeout(successTimer)
|
|
77
97
|
})
|
|
78
98
|
|
|
79
99
|
// Format status for display (e.g., "ready_to_extract" -> "Ready to extract")
|
|
@@ -84,33 +104,67 @@ function formatStatus(status: string): string {
|
|
|
84
104
|
|
|
85
105
|
<template>
|
|
86
106
|
<div ref="popoverRef" class="mld-experiment-popover">
|
|
87
|
-
<!--
|
|
88
|
-
<
|
|
89
|
-
type="button"
|
|
107
|
+
<!-- Split trigger: experiment pill + inline save -->
|
|
108
|
+
<div
|
|
90
109
|
:class="[
|
|
91
|
-
'mld-experiment-
|
|
92
|
-
{ 'mld-experiment-
|
|
93
|
-
{ 'mld-experiment-popover__trigger--empty': !experimentName },
|
|
110
|
+
'mld-experiment-popover__split',
|
|
111
|
+
{ 'mld-experiment-popover__split--with-save': showSave && experimentName },
|
|
94
112
|
]"
|
|
95
|
-
@click.stop="toggle"
|
|
96
113
|
>
|
|
97
|
-
<!--
|
|
98
|
-
<
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
+
<!-- Left: experiment trigger (opens popover) -->
|
|
115
|
+
<button
|
|
116
|
+
type="button"
|
|
117
|
+
:class="[
|
|
118
|
+
'mld-experiment-popover__trigger',
|
|
119
|
+
{ 'mld-experiment-popover__trigger--active': isOpen },
|
|
120
|
+
{ 'mld-experiment-popover__trigger--empty': !experimentName },
|
|
121
|
+
]"
|
|
122
|
+
@click.stop="toggle"
|
|
123
|
+
>
|
|
124
|
+
<!-- Flask icon -->
|
|
125
|
+
<svg class="mld-experiment-popover__trigger-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
126
|
+
<path
|
|
127
|
+
stroke-linecap="round"
|
|
128
|
+
stroke-linejoin="round"
|
|
129
|
+
stroke-width="1.75"
|
|
130
|
+
d="M19.428 15.428a2 2 0 00-1.022-.547l-2.387-.477a6 6 0 00-3.86.517l-.318.158a6 6 0 01-3.86.517L6.05 15.21a2 2 0 00-1.806.547M8 4h8l-1 1v5.172a2 2 0 00.586 1.414l5 5c1.26 1.26.367 3.414-1.415 3.414H4.828c-1.782 0-2.674-2.154-1.414-3.414l5-5A2 2 0 009 10.172V5L8 4z"
|
|
131
|
+
/>
|
|
132
|
+
</svg>
|
|
133
|
+
<span class="mld-experiment-popover__trigger-text">
|
|
134
|
+
{{ experimentName || 'No experiment' }}
|
|
135
|
+
</span>
|
|
136
|
+
<!-- Chevron -->
|
|
137
|
+
<svg class="mld-experiment-popover__trigger-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
138
|
+
<path d="m6 9 6 6 6-6" />
|
|
139
|
+
</svg>
|
|
140
|
+
</button>
|
|
141
|
+
|
|
142
|
+
<!-- Right: inline save button (direct action) -->
|
|
143
|
+
<button
|
|
144
|
+
v-if="showSave && experimentName"
|
|
145
|
+
type="button"
|
|
146
|
+
:class="[
|
|
147
|
+
'mld-experiment-popover__save-trigger',
|
|
148
|
+
{ 'mld-experiment-popover__save-trigger--loading': saveLoading },
|
|
149
|
+
{ 'mld-experiment-popover__save-trigger--success': showSuccess },
|
|
150
|
+
{ 'mld-experiment-popover__save-trigger--disabled': saveDisabled && !showSuccess },
|
|
151
|
+
]"
|
|
152
|
+
:disabled="saveDisabled && !showSuccess"
|
|
153
|
+
:title="saveDisabled && saveDisabledMessage ? saveDisabledMessage : showSuccess && saveSuccessMessage ? saveSuccessMessage : 'Save to Experiment'"
|
|
154
|
+
@click.stop="handleSave"
|
|
155
|
+
>
|
|
156
|
+
<!-- Loading spinner -->
|
|
157
|
+
<span v-if="saveLoading" class="mld-experiment-popover__spinner--inline" />
|
|
158
|
+
<!-- Success check -->
|
|
159
|
+
<svg v-else-if="showSuccess" class="mld-experiment-popover__save-trigger-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
160
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M5 13l4 4L19 7" />
|
|
161
|
+
</svg>
|
|
162
|
+
<!-- Save icon -->
|
|
163
|
+
<svg v-else class="mld-experiment-popover__save-trigger-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
164
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7H5a2 2 0 00-2 2v9a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-3m-1 4l-3 3m0 0l-3-3m3 3V4" />
|
|
165
|
+
</svg>
|
|
166
|
+
</button>
|
|
167
|
+
</div>
|
|
114
168
|
|
|
115
169
|
<!-- Popover panel -->
|
|
116
170
|
<div v-if="isOpen" class="mld-experiment-popover__panel">
|
|
@@ -161,40 +215,18 @@ function formatStatus(status: string): string {
|
|
|
161
215
|
</div>
|
|
162
216
|
</div>
|
|
163
217
|
</div>
|
|
164
|
-
|
|
165
|
-
<!-- Save section -->
|
|
166
|
-
<template v-if="showSave">
|
|
167
|
-
<div class="mld-experiment-popover__divider" />
|
|
168
|
-
<div class="mld-experiment-popover__footer">
|
|
169
|
-
<button
|
|
170
|
-
type="button"
|
|
171
|
-
:class="[
|
|
172
|
-
'mld-experiment-popover__save-btn',
|
|
173
|
-
{ 'mld-experiment-popover__save-btn--loading': saveLoading },
|
|
174
|
-
{ 'mld-experiment-popover__save-btn--success': showSuccess },
|
|
175
|
-
]"
|
|
176
|
-
:disabled="saveDisabled && !showSuccess"
|
|
177
|
-
@click="handleSave"
|
|
178
|
-
>
|
|
179
|
-
<!-- Loading spinner -->
|
|
180
|
-
<span v-if="saveLoading" class="mld-experiment-popover__spinner" />
|
|
181
|
-
<!-- Success check -->
|
|
182
|
-
<svg v-else-if="showSuccess" class="mld-experiment-popover__check-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
183
|
-
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
|
184
|
-
</svg>
|
|
185
|
-
<!-- Save icon -->
|
|
186
|
-
<svg v-else class="mld-experiment-popover__save-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
187
|
-
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7H5a2 2 0 00-2 2v9a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-3m-1 4l-3 3m0 0l-3-3m3 3V4" />
|
|
188
|
-
</svg>
|
|
189
|
-
<!-- Label -->
|
|
190
|
-
<span>{{ showSuccess && saveSuccessMessage ? saveSuccessMessage : 'Save to Experiment' }}</span>
|
|
191
|
-
</button>
|
|
192
|
-
<div v-if="saveDisabled && saveDisabledMessage && !showSuccess" class="mld-experiment-popover__save-hint">
|
|
193
|
-
{{ saveDisabledMessage }}
|
|
194
|
-
</div>
|
|
195
|
-
</div>
|
|
196
|
-
</template>
|
|
197
218
|
</div>
|
|
219
|
+
|
|
220
|
+
<!-- Save confirmation dialog -->
|
|
221
|
+
<ConfirmDialog
|
|
222
|
+
v-model="showConfirm"
|
|
223
|
+
:title="confirmTitle ?? 'Save to Experiment'"
|
|
224
|
+
:message="confirmMessage ?? `Save current data to ${experimentName}?`"
|
|
225
|
+
variant="info"
|
|
226
|
+
confirm-label="Save"
|
|
227
|
+
:loading="saveLoading"
|
|
228
|
+
@confirm="handleConfirmSave"
|
|
229
|
+
/>
|
|
198
230
|
</div>
|
|
199
231
|
</template>
|
|
200
232
|
|
|
@@ -61,6 +61,7 @@ export interface UseAuthReturn {
|
|
|
61
61
|
// across multiple useAuth() instances
|
|
62
62
|
let _refreshPromise: Promise<boolean> | null = null
|
|
63
63
|
let _refreshTimerId: number | null = null
|
|
64
|
+
let _mountedConsumerCount = 0
|
|
64
65
|
|
|
65
66
|
export function useAuth(): UseAuthReturn {
|
|
66
67
|
const authStore = useAuthStore()
|
|
@@ -385,15 +386,20 @@ export function useAuth(): UseAuthReturn {
|
|
|
385
386
|
|
|
386
387
|
if (getCurrentInstance()) {
|
|
387
388
|
onMounted(() => {
|
|
389
|
+
_mountedConsumerCount += 1
|
|
388
390
|
checkInterval = window.setInterval(checkAndRefreshIfNeeded, TOKEN_REFRESH_CHECK_INTERVAL_MS)
|
|
389
391
|
})
|
|
390
392
|
|
|
391
393
|
onUnmounted(() => {
|
|
392
|
-
stopTokenRefresh()
|
|
393
394
|
if (checkInterval !== null) {
|
|
394
395
|
window.clearInterval(checkInterval)
|
|
395
396
|
checkInterval = null
|
|
396
397
|
}
|
|
398
|
+
|
|
399
|
+
_mountedConsumerCount = Math.max(0, _mountedConsumerCount - 1)
|
|
400
|
+
if (_mountedConsumerCount === 0) {
|
|
401
|
+
stopTokenRefresh()
|
|
402
|
+
}
|
|
397
403
|
})
|
|
398
404
|
|
|
399
405
|
// Watch for token changes to reschedule refresh
|