@morscherlab/mint-sdk 1.0.56 → 1.0.58
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/__tests__/components/ArtifactSaveDialog.test.d.ts +1 -0
- package/dist/__tests__/components/ArtifactSelectorModal.test.d.ts +1 -0
- package/dist/__tests__/composables/useAnalysisArtifacts.test.d.ts +1 -0
- package/dist/analysisArtifactTypes-Cu40dzSG.js +7 -0
- package/dist/analysisArtifactTypes-Cu40dzSG.js.map +1 -0
- package/dist/components/ArtifactSaveDialog.vue.d.ts +59 -0
- package/dist/components/ArtifactSelectorModal.vue.d.ts +41 -0
- package/dist/components/index.d.ts +2 -0
- package/dist/components/index.js +2 -2
- package/dist/{components-BQwOeyiy.js → components-CpnOaPbP.js} +1774 -1144
- package/dist/components-CpnOaPbP.js.map +1 -0
- package/dist/composables/index.d.ts +1 -0
- package/dist/composables/index.js +4 -3
- package/dist/composables/useAnalysisArtifacts.d.ts +59 -0
- package/dist/{composables-TpXyhRZZ.js → composables-OYTD0Y3r.js} +2 -2
- package/dist/{composables-TpXyhRZZ.js.map → composables-OYTD0Y3r.js.map} +1 -1
- package/dist/index.js +5 -4
- package/dist/index.js.map +1 -1
- package/dist/install.js +1 -1
- package/dist/styles.css +412 -0
- package/dist/types/analysisArtifactTypes.d.ts +133 -0
- package/dist/types/index.d.ts +2 -0
- package/dist/types/index.js +2 -0
- package/dist/{useJobsStatusTray-CVecN6Ao.js → useAnalysisArtifacts-Ob8UtVwl.js} +157 -2
- package/dist/useAnalysisArtifacts-Ob8UtVwl.js.map +1 -0
- package/package.json +1 -1
- package/src/__tests__/components/ArtifactSaveDialog.test.ts +566 -0
- package/src/__tests__/components/ArtifactSelectorModal.test.ts +178 -0
- package/src/__tests__/composables/useAnalysisArtifacts.test.ts +667 -0
- package/src/components/ArtifactSaveDialog.story.vue +319 -0
- package/src/components/ArtifactSaveDialog.vue +434 -0
- package/src/components/ArtifactSelectorModal.story.vue +295 -0
- package/src/components/ArtifactSelectorModal.vue +326 -0
- package/src/components/index.ts +2 -0
- package/src/composables/index.ts +6 -0
- package/src/composables/useAnalysisArtifacts.ts +305 -0
- package/src/styles/components/artifact-save-dialog.css +17 -0
- package/src/styles/components/artifact-selector-modal.css +216 -0
- package/src/styles/index.css +2 -0
- package/src/types/analysisArtifactTypes.ts +167 -0
- package/src/types/index.ts +24 -0
- package/dist/components-BQwOeyiy.js.map +0 -1
- package/dist/useJobsStatusTray-CVecN6Ao.js.map +0 -1
- /package/dist/{ExperimentPopover-8A4Rhffp.js → ExperimentPopover-Ryusjrhv.js} +0 -0
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { ref, nextTick, onMounted, onUnmounted } from 'vue'
|
|
3
|
+
import type { InternalAxiosRequestConfig } from 'axios'
|
|
4
|
+
import ArtifactSelectorModal from './ArtifactSelectorModal.vue'
|
|
5
|
+
import {
|
|
6
|
+
ANALYSIS_FILE_ARTIFACT_SCHEMA,
|
|
7
|
+
type AnalysisArtifactSummary,
|
|
8
|
+
} from '../types/analysisArtifactTypes'
|
|
9
|
+
|
|
10
|
+
// -- Mock data: artifacts from two analysis plugins on one experiment --
|
|
11
|
+
const mockArtifacts: AnalysisArtifactSummary[] = [
|
|
12
|
+
{
|
|
13
|
+
id: 1,
|
|
14
|
+
experiment_id: 42,
|
|
15
|
+
plugin_id: 'drp',
|
|
16
|
+
artifact_key: 'dose-response-fit',
|
|
17
|
+
display_name: 'Doxorubicin IC50 fit',
|
|
18
|
+
note: 'Four-parameter logistic, R² = 0.994',
|
|
19
|
+
status: 'active',
|
|
20
|
+
result_keys: ['curves', 'ic50', 'summary'],
|
|
21
|
+
created_at: '2026-07-01T10:00:00Z',
|
|
22
|
+
updated_at: '2026-07-02T09:15:00Z',
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
id: 2,
|
|
26
|
+
experiment_id: 42,
|
|
27
|
+
plugin_id: 'drp',
|
|
28
|
+
artifact_key: 'synergy-matrix',
|
|
29
|
+
display_name: 'DOX + 5-FU synergy matrix',
|
|
30
|
+
status: 'active',
|
|
31
|
+
result_keys: ['bliss', 'hsa'],
|
|
32
|
+
created_at: '2026-07-01T11:30:00Z',
|
|
33
|
+
updated_at: '2026-07-01T11:30:00Z',
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
id: 3,
|
|
37
|
+
experiment_id: 42,
|
|
38
|
+
plugin_id: 'drp',
|
|
39
|
+
artifact_key: 'qc-report',
|
|
40
|
+
display_name: 'QC report (PDF)',
|
|
41
|
+
note: 'Generated after plate re-read',
|
|
42
|
+
status: 'active',
|
|
43
|
+
result_keys: ['schema_version', 'kind', 'filename', 'file'],
|
|
44
|
+
created_at: '2026-07-02T14:00:00Z',
|
|
45
|
+
updated_at: '2026-07-02T14:00:00Z',
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
id: 4,
|
|
49
|
+
experiment_id: 42,
|
|
50
|
+
plugin_id: 'ms-uploader',
|
|
51
|
+
artifact_key: 'peak-table',
|
|
52
|
+
display_name: 'Peak table (raw)',
|
|
53
|
+
status: 'active',
|
|
54
|
+
result_keys: ['peak_data', 'sample_names', 'upload_info'],
|
|
55
|
+
created_at: '2026-06-28T08:00:00Z',
|
|
56
|
+
updated_at: '2026-06-28T08:00:00Z',
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
id: 5,
|
|
60
|
+
experiment_id: 42,
|
|
61
|
+
plugin_id: 'ms-uploader',
|
|
62
|
+
artifact_key: 'chromatogram',
|
|
63
|
+
display_name: 'TIC chromatogram (PNG)',
|
|
64
|
+
status: 'active',
|
|
65
|
+
result_keys: ['schema_version', 'kind', 'filename', 'file'],
|
|
66
|
+
created_at: '2026-06-28T08:05:00Z',
|
|
67
|
+
updated_at: '2026-06-28T08:05:00Z',
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
id: 6,
|
|
71
|
+
experiment_id: 42,
|
|
72
|
+
plugin_id: 'drp',
|
|
73
|
+
artifact_key: 'old-fit',
|
|
74
|
+
display_name: 'Superseded IC50 fit',
|
|
75
|
+
note: 'Replaced after outlier removal',
|
|
76
|
+
status: 'archived',
|
|
77
|
+
result_keys: ['curves', 'ic50'],
|
|
78
|
+
created_at: '2026-06-20T09:00:00Z',
|
|
79
|
+
updated_at: '2026-06-25T16:00:00Z',
|
|
80
|
+
archived_at: '2026-06-25T16:00:00Z',
|
|
81
|
+
archived_by: 3,
|
|
82
|
+
},
|
|
83
|
+
]
|
|
84
|
+
|
|
85
|
+
const mockDetails: Record<number, Record<string, unknown>> = {
|
|
86
|
+
3: {
|
|
87
|
+
schema_version: ANALYSIS_FILE_ARTIFACT_SCHEMA,
|
|
88
|
+
kind: 'report',
|
|
89
|
+
filename: 'qc-report.pdf',
|
|
90
|
+
file: { uri: 'mint-object://experiments/42/plugins/drp/analysis-artifacts/qc-report/qc-report.pdf' },
|
|
91
|
+
},
|
|
92
|
+
5: {
|
|
93
|
+
schema_version: ANALYSIS_FILE_ARTIFACT_SCHEMA,
|
|
94
|
+
kind: 'plot',
|
|
95
|
+
filename: 'tic.png',
|
|
96
|
+
file: { uri: 'mint-object://experiments/42/plugins/ms-uploader/analysis-artifacts/chromatogram/tic.png' },
|
|
97
|
+
},
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// -- Intercept API calls to return mock data --
|
|
101
|
+
// Ejected on unmount so remounts and HMR don't stack interceptors on the shared client.
|
|
102
|
+
let interceptorId: number | null = null
|
|
103
|
+
let ejectInterceptor: (() => void) | null = null
|
|
104
|
+
|
|
105
|
+
onUnmounted(() => {
|
|
106
|
+
ejectInterceptor?.()
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
onMounted(async () => {
|
|
110
|
+
const { useApi } = await import('../composables/useApi')
|
|
111
|
+
const { client } = useApi()
|
|
112
|
+
const state = mockArtifacts.map((artifact) => ({ ...artifact }))
|
|
113
|
+
|
|
114
|
+
function respond(config: InternalAxiosRequestConfig, data: unknown) {
|
|
115
|
+
return () => Promise.resolve({
|
|
116
|
+
data,
|
|
117
|
+
status: 200,
|
|
118
|
+
statusText: 'OK',
|
|
119
|
+
headers: {},
|
|
120
|
+
config,
|
|
121
|
+
})
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
interceptorId = client.interceptors.request.use((config) => {
|
|
125
|
+
const url = config.url ?? ''
|
|
126
|
+
if (!url.includes('/analysis-artifacts')) return config
|
|
127
|
+
|
|
128
|
+
const method = (config.method ?? 'get').toLowerCase()
|
|
129
|
+
const idMatch = url.match(/\/analysis-artifacts\/(\d+)/)
|
|
130
|
+
const artifactId = idMatch ? Number(idMatch[1]) : undefined
|
|
131
|
+
const artifact = state.find((row) => row.id === artifactId)
|
|
132
|
+
|
|
133
|
+
if (method === 'post' && artifact) {
|
|
134
|
+
artifact.status = url.endsWith('/archive') ? 'archived' : 'active'
|
|
135
|
+
artifact.archived_at = url.endsWith('/archive') ? new Date().toISOString() : null
|
|
136
|
+
config.adapter = respond(config, { ...artifact })
|
|
137
|
+
return config
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (method === 'patch' && artifact) {
|
|
141
|
+
Object.assign(artifact, config.data ? JSON.parse(String(config.data)) : {})
|
|
142
|
+
config.adapter = respond(config, { ...artifact })
|
|
143
|
+
return config
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (artifactId && artifact) {
|
|
147
|
+
config.adapter = respond(config, {
|
|
148
|
+
...artifact,
|
|
149
|
+
result: mockDetails[artifactId] ?? { summary: { rows: 96 } },
|
|
150
|
+
tree: [],
|
|
151
|
+
summary: null,
|
|
152
|
+
})
|
|
153
|
+
return config
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const params = new URLSearchParams(url.split('?')[1] ?? '')
|
|
157
|
+
const includeArchived = params.get('include_archived') === 'true'
|
|
158
|
+
const rows = includeArchived ? state : state.filter((row) => row.status === 'active')
|
|
159
|
+
config.adapter = respond(config, { artifacts: rows.map((row) => ({ ...row })) })
|
|
160
|
+
return config
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
ejectInterceptor = () => {
|
|
164
|
+
if (interceptorId !== null) {
|
|
165
|
+
client.interceptors.request.eject(interceptorId)
|
|
166
|
+
interceptorId = null
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
await nextTick()
|
|
171
|
+
isOpen.value = true
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
// -- Story state --
|
|
175
|
+
const isOpen = ref(false)
|
|
176
|
+
const isOpenScoped = ref(false)
|
|
177
|
+
const isOpenArchived = ref(false)
|
|
178
|
+
const isOpenEmpty = ref(false)
|
|
179
|
+
const selectedArtifact = ref<AnalysisArtifactSummary | null>(null)
|
|
180
|
+
|
|
181
|
+
function handleSelect(artifact: AnalysisArtifactSummary) {
|
|
182
|
+
selectedArtifact.value = artifact
|
|
183
|
+
}
|
|
184
|
+
</script>
|
|
185
|
+
|
|
186
|
+
<template>
|
|
187
|
+
<Story title="Experiment/ArtifactSelectorModal">
|
|
188
|
+
<Variant title="Playground">
|
|
189
|
+
<template #default="{ state }">
|
|
190
|
+
<div style="padding: 2rem;">
|
|
191
|
+
<button
|
|
192
|
+
style="padding: 0.5rem 1rem; background: var(--color-primary, #6366F1); color: white; border: none; border-radius: 6px; cursor: pointer; font-size: 0.875rem;"
|
|
193
|
+
@click="isOpen = true"
|
|
194
|
+
>
|
|
195
|
+
Open Artifact Selector
|
|
196
|
+
</button>
|
|
197
|
+
|
|
198
|
+
<div
|
|
199
|
+
v-if="selectedArtifact"
|
|
200
|
+
style="margin-top: 1rem; padding: 0.75rem 1rem; background: var(--bg-card, #fff); border: 1px solid var(--border-color, #e2e8f0); border-radius: 6px; display: flex; align-items: center; gap: 0.5rem;"
|
|
201
|
+
>
|
|
202
|
+
<span style="font-size: 0.8125rem; color: var(--text-muted, #94a3b8);">Selected:</span>
|
|
203
|
+
<code style="font-size: 0.75rem; color: var(--text-secondary, #475569);">
|
|
204
|
+
{{ selectedArtifact.plugin_id }}/{{ selectedArtifact.artifact_key }}
|
|
205
|
+
</code>
|
|
206
|
+
<span style="font-size: 0.875rem; font-weight: 500; color: var(--text-primary, #1e293b);">
|
|
207
|
+
{{ selectedArtifact.display_name }}
|
|
208
|
+
</span>
|
|
209
|
+
</div>
|
|
210
|
+
|
|
211
|
+
<ArtifactSelectorModal
|
|
212
|
+
v-model="isOpen"
|
|
213
|
+
:experiment-id="42"
|
|
214
|
+
:title="state.title"
|
|
215
|
+
:include-archived="state.includeArchived"
|
|
216
|
+
:fetch-detail-on-select="state.fetchDetailOnSelect"
|
|
217
|
+
:current-artifact-id="selectedArtifact?.id ?? null"
|
|
218
|
+
@select="handleSelect"
|
|
219
|
+
/>
|
|
220
|
+
</div>
|
|
221
|
+
</template>
|
|
222
|
+
|
|
223
|
+
<template #controls="{ state }">
|
|
224
|
+
<HstCheckbox v-model="isOpen" title="Open" />
|
|
225
|
+
<HstText v-model="state.title" title="Title" />
|
|
226
|
+
<HstCheckbox v-model="state.includeArchived" title="Include archived" />
|
|
227
|
+
<HstCheckbox v-model="state.fetchDetailOnSelect" title="Fetch detail on select" />
|
|
228
|
+
</template>
|
|
229
|
+
</Variant>
|
|
230
|
+
|
|
231
|
+
<Variant title="With Archived">
|
|
232
|
+
<div style="padding: 2rem;">
|
|
233
|
+
<p style="font-size: 0.8125rem; color: var(--text-muted, #94a3b8); margin: 0 0 1rem;">
|
|
234
|
+
Opens with archived artifacts visible. Archived rows are muted and offer Restore.
|
|
235
|
+
</p>
|
|
236
|
+
<button
|
|
237
|
+
style="padding: 0.5rem 1rem; background: var(--color-primary, #6366F1); color: white; border: none; border-radius: 6px; cursor: pointer; font-size: 0.875rem;"
|
|
238
|
+
@click="isOpenArchived = true"
|
|
239
|
+
>
|
|
240
|
+
Open With Archived
|
|
241
|
+
</button>
|
|
242
|
+
|
|
243
|
+
<ArtifactSelectorModal
|
|
244
|
+
v-model="isOpenArchived"
|
|
245
|
+
:experiment-id="42"
|
|
246
|
+
:include-archived="true"
|
|
247
|
+
@select="handleSelect"
|
|
248
|
+
/>
|
|
249
|
+
</div>
|
|
250
|
+
</Variant>
|
|
251
|
+
|
|
252
|
+
<Variant title="Single Plugin Scoped">
|
|
253
|
+
<div style="padding: 2rem;">
|
|
254
|
+
<p style="font-size: 0.8125rem; color: var(--text-muted, #94a3b8); margin: 0 0 1rem;">
|
|
255
|
+
Scoped to the <code>ms-uploader</code> plugin, so the plugin filter is hidden.
|
|
256
|
+
</p>
|
|
257
|
+
<button
|
|
258
|
+
style="padding: 0.5rem 1rem; background: var(--color-primary, #6366F1); color: white; border: none; border-radius: 6px; cursor: pointer; font-size: 0.875rem;"
|
|
259
|
+
@click="isOpenScoped = true"
|
|
260
|
+
>
|
|
261
|
+
Open Scoped Selector
|
|
262
|
+
</button>
|
|
263
|
+
|
|
264
|
+
<ArtifactSelectorModal
|
|
265
|
+
v-model="isOpenScoped"
|
|
266
|
+
:experiment-id="42"
|
|
267
|
+
plugin-id="ms-uploader"
|
|
268
|
+
title="Select Peak Data"
|
|
269
|
+
@select="handleSelect"
|
|
270
|
+
/>
|
|
271
|
+
</div>
|
|
272
|
+
</Variant>
|
|
273
|
+
|
|
274
|
+
<Variant title="Empty State">
|
|
275
|
+
<div style="padding: 2rem;">
|
|
276
|
+
<p style="font-size: 0.8125rem; color: var(--text-muted, #94a3b8); margin: 0 0 1rem;">
|
|
277
|
+
Scoped to a plugin with no artifacts, showing the empty state.
|
|
278
|
+
</p>
|
|
279
|
+
<button
|
|
280
|
+
style="padding: 0.5rem 1rem; background: var(--color-primary, #6366F1); color: white; border: none; border-radius: 6px; cursor: pointer; font-size: 0.875rem;"
|
|
281
|
+
@click="isOpenEmpty = true"
|
|
282
|
+
>
|
|
283
|
+
Open Selector (Empty)
|
|
284
|
+
</button>
|
|
285
|
+
|
|
286
|
+
<ArtifactSelectorModal
|
|
287
|
+
v-model="isOpenEmpty"
|
|
288
|
+
:experiment-id="42"
|
|
289
|
+
plugin-id="nonexistent-plugin"
|
|
290
|
+
@select="handleSelect"
|
|
291
|
+
/>
|
|
292
|
+
</div>
|
|
293
|
+
</Variant>
|
|
294
|
+
</Story>
|
|
295
|
+
</template>
|
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/** Modal for browsing and selecting an analysis artifact of an experiment, with filters and keyboard nav. */
|
|
3
|
+
import { computed, ref, watch, nextTick } from 'vue'
|
|
4
|
+
import type { ModalSize } from '../types'
|
|
5
|
+
import type {
|
|
6
|
+
AnalysisArtifactDetail,
|
|
7
|
+
AnalysisArtifactStatusFilter,
|
|
8
|
+
AnalysisArtifactSummary,
|
|
9
|
+
} from '../types/analysisArtifactTypes'
|
|
10
|
+
import { useAnalysisArtifacts } from '../composables/useAnalysisArtifacts'
|
|
11
|
+
import { formatExperimentDate } from '../composables/experiment-utils'
|
|
12
|
+
import { getInjectedPlatformContext } from '../composables/platformContextHelpers'
|
|
13
|
+
import { hasAllPermissions } from '../permissions'
|
|
14
|
+
import BaseModal from './BaseModal.vue'
|
|
15
|
+
import BaseInput from './BaseInput.vue'
|
|
16
|
+
import BaseSelect from './BaseSelect.vue'
|
|
17
|
+
import BasePill from './BasePill.vue'
|
|
18
|
+
import Skeleton from './Skeleton.vue'
|
|
19
|
+
import EmptyState from './EmptyState.vue'
|
|
20
|
+
|
|
21
|
+
interface Props {
|
|
22
|
+
modelValue: boolean
|
|
23
|
+
/** Experiment id override; defaults to platform injection or URL. */
|
|
24
|
+
experimentId?: number | null
|
|
25
|
+
/** Scope to a single owning plugin; omit to show all plugins. */
|
|
26
|
+
pluginId?: string | null
|
|
27
|
+
/** Highlights the currently used artifact. */
|
|
28
|
+
currentArtifactId?: number | null
|
|
29
|
+
/** Start with archived artifacts visible. */
|
|
30
|
+
includeArchived?: boolean
|
|
31
|
+
/** Fetch the full detail before emitting selectDetail. */
|
|
32
|
+
fetchDetailOnSelect?: boolean
|
|
33
|
+
title?: string
|
|
34
|
+
size?: ModalSize
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const props = withDefaults(defineProps<Props>(), {
|
|
38
|
+
experimentId: null,
|
|
39
|
+
pluginId: null,
|
|
40
|
+
currentArtifactId: null,
|
|
41
|
+
includeArchived: false,
|
|
42
|
+
fetchDetailOnSelect: false,
|
|
43
|
+
title: 'Select Analysis Artifact',
|
|
44
|
+
size: 'full',
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
const emit = defineEmits<{
|
|
48
|
+
'update:modelValue': [value: boolean]
|
|
49
|
+
select: [artifact: AnalysisArtifactSummary]
|
|
50
|
+
selectDetail: [detail: AnalysisArtifactDetail]
|
|
51
|
+
archive: [artifact: AnalysisArtifactSummary]
|
|
52
|
+
restore: [artifact: AnalysisArtifactSummary]
|
|
53
|
+
}>()
|
|
54
|
+
|
|
55
|
+
const {
|
|
56
|
+
artifacts,
|
|
57
|
+
visibleArtifacts,
|
|
58
|
+
isLoading,
|
|
59
|
+
isMutating,
|
|
60
|
+
error,
|
|
61
|
+
search,
|
|
62
|
+
statusFilter,
|
|
63
|
+
pluginFilter,
|
|
64
|
+
list,
|
|
65
|
+
getDetail,
|
|
66
|
+
archive,
|
|
67
|
+
restore,
|
|
68
|
+
} = useAnalysisArtifacts({
|
|
69
|
+
experimentId: () => props.experimentId ?? undefined,
|
|
70
|
+
pluginId: () => props.pluginId,
|
|
71
|
+
includeArchived: () => props.includeArchived,
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
const injectedContext = getInjectedPlatformContext()
|
|
75
|
+
// Without injected user info (standalone/dev mode) the backend stays the authority.
|
|
76
|
+
const canManage = computed(() => {
|
|
77
|
+
const user = injectedContext?.user
|
|
78
|
+
return user ? hasAllPermissions(user, ['experiments.edit']) : true
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
const STATUS_FILTER_OPTIONS: Array<{ value: AnalysisArtifactStatusFilter; label: string }> = [
|
|
82
|
+
{ value: 'active', label: 'Active' },
|
|
83
|
+
{ value: 'archived', label: 'Archived' },
|
|
84
|
+
{ value: 'all', label: 'All statuses' },
|
|
85
|
+
]
|
|
86
|
+
|
|
87
|
+
const pluginFilterOptions = computed(() => [
|
|
88
|
+
{ value: '', label: 'All plugins' },
|
|
89
|
+
...[...new Set(artifacts.value.map((artifact) => artifact.plugin_id))]
|
|
90
|
+
.sort()
|
|
91
|
+
.map((pluginId) => ({ value: pluginId, label: pluginId })),
|
|
92
|
+
])
|
|
93
|
+
const canChoosePlugin = computed(
|
|
94
|
+
() => !props.pluginId && pluginFilterOptions.value.length > 2,
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
const activeIndex = ref(-1)
|
|
98
|
+
const listRef = ref<HTMLElement | null>(null)
|
|
99
|
+
|
|
100
|
+
const LISTBOX_ID = 'mint-artifact-selector-listbox'
|
|
101
|
+
function optionId(artifact: AnalysisArtifactSummary): string {
|
|
102
|
+
return `mint-artifact-option-${artifact.id}`
|
|
103
|
+
}
|
|
104
|
+
const activeDescendantId = computed(() => {
|
|
105
|
+
const artifact = visibleArtifacts.value[activeIndex.value]
|
|
106
|
+
return activeIndex.value >= 0 && artifact ? optionId(artifact) : undefined
|
|
107
|
+
})
|
|
108
|
+
const hasResults = computed(
|
|
109
|
+
() => !isLoading.value && !error.value && visibleArtifacts.value.length > 0,
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
function statusVariant(status: string): 'success' | 'warning' {
|
|
113
|
+
return status === 'archived' ? 'warning' : 'success'
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function formatArtifactDate(artifact: AnalysisArtifactSummary): string {
|
|
117
|
+
const date = artifact.updated_at ?? artifact.created_at
|
|
118
|
+
return date ? formatExperimentDate(date) : ''
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function handleSelect(artifact: AnalysisArtifactSummary): Promise<void> {
|
|
122
|
+
if (props.fetchDetailOnSelect) {
|
|
123
|
+
const detail = await getDetail(artifact.id)
|
|
124
|
+
// Stay open on failure: closing would look like a successful pick while the
|
|
125
|
+
// consumer waiting on selectDetail got nothing.
|
|
126
|
+
if (!detail) return
|
|
127
|
+
emit('selectDetail', detail)
|
|
128
|
+
}
|
|
129
|
+
emit('select', artifact)
|
|
130
|
+
emit('update:modelValue', false)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Emit what the backend returned, not the row we started from: the pre-mutation
|
|
134
|
+
// object still carries the old status.
|
|
135
|
+
async function handleArchive(artifact: AnalysisArtifactSummary): Promise<void> {
|
|
136
|
+
const archived = await archive(artifact.id)
|
|
137
|
+
if (archived) emit('archive', archived)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async function handleRestore(artifact: AnalysisArtifactSummary): Promise<void> {
|
|
141
|
+
const restored = await restore(artifact.id)
|
|
142
|
+
if (restored) emit('restore', restored)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function handleKeydown(event: KeyboardEvent): void {
|
|
146
|
+
const rows = visibleArtifacts.value
|
|
147
|
+
if (!rows.length) return
|
|
148
|
+
// Let the row's own Archive/Restore button handle its keys natively.
|
|
149
|
+
if ((event.target as HTMLElement | null)?.closest('.mint-artifact-selector__action')) return
|
|
150
|
+
|
|
151
|
+
switch (event.key) {
|
|
152
|
+
case 'ArrowDown':
|
|
153
|
+
event.preventDefault()
|
|
154
|
+
activeIndex.value = Math.min(activeIndex.value + 1, rows.length - 1)
|
|
155
|
+
scrollActiveIntoView()
|
|
156
|
+
break
|
|
157
|
+
case 'ArrowUp':
|
|
158
|
+
event.preventDefault()
|
|
159
|
+
activeIndex.value = Math.max(activeIndex.value - 1, 0)
|
|
160
|
+
scrollActiveIntoView()
|
|
161
|
+
break
|
|
162
|
+
case 'Enter':
|
|
163
|
+
event.preventDefault()
|
|
164
|
+
if (activeIndex.value >= 0 && activeIndex.value < rows.length) {
|
|
165
|
+
void handleSelect(rows[activeIndex.value])
|
|
166
|
+
}
|
|
167
|
+
break
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function scrollActiveIntoView(): void {
|
|
172
|
+
nextTick(() => {
|
|
173
|
+
const row = listRef.value?.querySelector('.mint-artifact-selector__row--focused')
|
|
174
|
+
row?.scrollIntoView({ block: 'nearest' })
|
|
175
|
+
})
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
watch(visibleArtifacts, () => { activeIndex.value = -1 })
|
|
179
|
+
|
|
180
|
+
watch(
|
|
181
|
+
() => props.modelValue,
|
|
182
|
+
(isOpen) => {
|
|
183
|
+
if (isOpen) {
|
|
184
|
+
activeIndex.value = -1
|
|
185
|
+
statusFilter.value = props.includeArchived ? 'all' : 'active'
|
|
186
|
+
void list()
|
|
187
|
+
}
|
|
188
|
+
},
|
|
189
|
+
{ immediate: true },
|
|
190
|
+
)
|
|
191
|
+
</script>
|
|
192
|
+
|
|
193
|
+
<template>
|
|
194
|
+
<BaseModal
|
|
195
|
+
:model-value="modelValue"
|
|
196
|
+
:title="title"
|
|
197
|
+
:size="size"
|
|
198
|
+
@update:model-value="emit('update:modelValue', $event)"
|
|
199
|
+
>
|
|
200
|
+
<div class="mint-artifact-selector" @keydown="handleKeydown">
|
|
201
|
+
<!-- Filter row -->
|
|
202
|
+
<div class="mint-artifact-selector__filters-row">
|
|
203
|
+
<div class="mint-artifact-selector__search">
|
|
204
|
+
<BaseInput
|
|
205
|
+
v-model="search"
|
|
206
|
+
placeholder="Search artifacts..."
|
|
207
|
+
size="sm"
|
|
208
|
+
type="search"
|
|
209
|
+
role="combobox"
|
|
210
|
+
aria-label="Search analysis artifacts"
|
|
211
|
+
:aria-expanded="hasResults"
|
|
212
|
+
:aria-controls="LISTBOX_ID"
|
|
213
|
+
:aria-activedescendant="activeDescendantId"
|
|
214
|
+
/>
|
|
215
|
+
</div>
|
|
216
|
+
<div class="mint-artifact-selector__filter-select">
|
|
217
|
+
<BaseSelect
|
|
218
|
+
:model-value="statusFilter"
|
|
219
|
+
:options="STATUS_FILTER_OPTIONS"
|
|
220
|
+
size="sm"
|
|
221
|
+
aria-label="Filter by status"
|
|
222
|
+
@update:model-value="v => statusFilter = v as AnalysisArtifactStatusFilter"
|
|
223
|
+
/>
|
|
224
|
+
</div>
|
|
225
|
+
<div v-if="canChoosePlugin" class="mint-artifact-selector__filter-select">
|
|
226
|
+
<BaseSelect
|
|
227
|
+
:model-value="pluginFilter ?? ''"
|
|
228
|
+
:options="pluginFilterOptions"
|
|
229
|
+
size="sm"
|
|
230
|
+
aria-label="Filter by plugin"
|
|
231
|
+
@update:model-value="v => pluginFilter = String(v) || null"
|
|
232
|
+
/>
|
|
233
|
+
</div>
|
|
234
|
+
</div>
|
|
235
|
+
|
|
236
|
+
<!-- Loading skeleton -->
|
|
237
|
+
<div v-if="isLoading" class="mint-artifact-selector__skeleton">
|
|
238
|
+
<div v-for="n in 4" :key="n" class="mint-artifact-selector__skeleton-row">
|
|
239
|
+
<div class="mint-artifact-selector__skeleton-content">
|
|
240
|
+
<Skeleton :width="120 + n * 20" height="14px" />
|
|
241
|
+
<Skeleton width="80px" height="10px" />
|
|
242
|
+
</div>
|
|
243
|
+
<Skeleton width="60px" height="20px" variant="rounded" />
|
|
244
|
+
</div>
|
|
245
|
+
</div>
|
|
246
|
+
|
|
247
|
+
<!-- Error -->
|
|
248
|
+
<div v-else-if="error" class="mint-artifact-selector__error">
|
|
249
|
+
{{ error }}
|
|
250
|
+
</div>
|
|
251
|
+
|
|
252
|
+
<!-- Empty -->
|
|
253
|
+
<EmptyState
|
|
254
|
+
v-else-if="visibleArtifacts.length === 0"
|
|
255
|
+
title="No analysis artifacts found"
|
|
256
|
+
description="Artifacts appear here once an analysis saves results for this experiment."
|
|
257
|
+
size="sm"
|
|
258
|
+
/>
|
|
259
|
+
|
|
260
|
+
<!-- Artifact list -->
|
|
261
|
+
<div
|
|
262
|
+
v-else
|
|
263
|
+
:id="LISTBOX_ID"
|
|
264
|
+
ref="listRef"
|
|
265
|
+
class="mint-artifact-selector__list"
|
|
266
|
+
role="listbox"
|
|
267
|
+
aria-label="Analysis artifacts"
|
|
268
|
+
>
|
|
269
|
+
<div
|
|
270
|
+
v-for="(artifact, idx) in visibleArtifacts"
|
|
271
|
+
:id="optionId(artifact)"
|
|
272
|
+
:key="artifact.id"
|
|
273
|
+
class="mint-artifact-selector__row"
|
|
274
|
+
role="option"
|
|
275
|
+
:aria-selected="artifact.id === currentArtifactId"
|
|
276
|
+
:class="{
|
|
277
|
+
'mint-artifact-selector__row--active': artifact.id === currentArtifactId,
|
|
278
|
+
'mint-artifact-selector__row--focused': idx === activeIndex,
|
|
279
|
+
'mint-artifact-selector__row--archived': artifact.status === 'archived',
|
|
280
|
+
}"
|
|
281
|
+
@click="handleSelect(artifact)"
|
|
282
|
+
@mouseenter="activeIndex = idx"
|
|
283
|
+
>
|
|
284
|
+
<div class="mint-artifact-selector__row-content">
|
|
285
|
+
<div class="mint-artifact-selector__name">
|
|
286
|
+
{{ artifact.display_name }}
|
|
287
|
+
<code class="mint-artifact-selector__key">{{ artifact.artifact_key }}</code>
|
|
288
|
+
</div>
|
|
289
|
+
<div class="mint-artifact-selector__meta">
|
|
290
|
+
<span class="mint-artifact-selector__plugin">{{ artifact.plugin_id }}</span>
|
|
291
|
+
<span>{{ artifact.result_keys.length }} result{{ artifact.result_keys.length === 1 ? '' : 's' }}</span>
|
|
292
|
+
<span>{{ formatArtifactDate(artifact) }}</span>
|
|
293
|
+
</div>
|
|
294
|
+
</div>
|
|
295
|
+
<div class="mint-artifact-selector__row-side">
|
|
296
|
+
<BasePill :variant="statusVariant(artifact.status)" size="sm">
|
|
297
|
+
{{ artifact.status }}
|
|
298
|
+
</BasePill>
|
|
299
|
+
<button
|
|
300
|
+
v-if="canManage && artifact.status === 'active'"
|
|
301
|
+
type="button"
|
|
302
|
+
class="mint-artifact-selector__action"
|
|
303
|
+
:disabled="isMutating"
|
|
304
|
+
@click.stop="handleArchive(artifact)"
|
|
305
|
+
>
|
|
306
|
+
Archive
|
|
307
|
+
</button>
|
|
308
|
+
<button
|
|
309
|
+
v-else-if="canManage && artifact.status === 'archived'"
|
|
310
|
+
type="button"
|
|
311
|
+
class="mint-artifact-selector__action"
|
|
312
|
+
:disabled="isMutating"
|
|
313
|
+
@click.stop="handleRestore(artifact)"
|
|
314
|
+
>
|
|
315
|
+
Restore
|
|
316
|
+
</button>
|
|
317
|
+
</div>
|
|
318
|
+
</div>
|
|
319
|
+
</div>
|
|
320
|
+
</div>
|
|
321
|
+
</BaseModal>
|
|
322
|
+
</template>
|
|
323
|
+
|
|
324
|
+
<style>
|
|
325
|
+
@import '../styles/components/artifact-selector-modal.css';
|
|
326
|
+
</style>
|
package/src/components/index.ts
CHANGED
|
@@ -126,5 +126,7 @@ export { default as ResourceCard } from './ResourceCard.vue'
|
|
|
126
126
|
|
|
127
127
|
// Experiment / analysis components
|
|
128
128
|
export { default as ExperimentSelectorModal } from './ExperimentSelectorModal.vue'
|
|
129
|
+
export { default as ArtifactSelectorModal } from './ArtifactSelectorModal.vue'
|
|
130
|
+
export { default as ArtifactSaveDialog } from './ArtifactSaveDialog.vue'
|
|
129
131
|
export { default as ExperimentPopover } from './ExperimentPopover.vue'
|
|
130
132
|
export { default as FitPanel } from './FitPanel.vue'
|
package/src/composables/index.ts
CHANGED
|
@@ -141,6 +141,12 @@ export {
|
|
|
141
141
|
type UseExperimentSelectorOptions,
|
|
142
142
|
type UseExperimentSelectorReturn,
|
|
143
143
|
} from './useExperimentSelector'
|
|
144
|
+
export {
|
|
145
|
+
useAnalysisArtifacts,
|
|
146
|
+
ANALYSIS_FILE_ARTIFACT_SCHEMA,
|
|
147
|
+
type UseAnalysisArtifactsOptions,
|
|
148
|
+
type UseAnalysisArtifactsReturn,
|
|
149
|
+
} from './useAnalysisArtifacts'
|
|
144
150
|
export {
|
|
145
151
|
formatExperimentDate,
|
|
146
152
|
formatExperimentStatus,
|