@morscherlab/mint-sdk 1.0.56 → 1.0.57

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.
Files changed (44) hide show
  1. package/dist/__tests__/components/ArtifactSaveDialog.test.d.ts +1 -0
  2. package/dist/__tests__/components/ArtifactSelectorModal.test.d.ts +1 -0
  3. package/dist/__tests__/composables/useAnalysisArtifacts.test.d.ts +1 -0
  4. package/dist/analysisArtifactTypes-Cu40dzSG.js +7 -0
  5. package/dist/analysisArtifactTypes-Cu40dzSG.js.map +1 -0
  6. package/dist/components/ArtifactSaveDialog.vue.d.ts +56 -0
  7. package/dist/components/ArtifactSelectorModal.vue.d.ts +41 -0
  8. package/dist/components/index.d.ts +2 -0
  9. package/dist/components/index.js +2 -2
  10. package/dist/{components-BQwOeyiy.js → components-PC3SVsaa.js} +1743 -1144
  11. package/dist/components-PC3SVsaa.js.map +1 -0
  12. package/dist/composables/index.d.ts +1 -0
  13. package/dist/composables/index.js +4 -3
  14. package/dist/composables/useAnalysisArtifacts.d.ts +59 -0
  15. package/dist/{composables-TpXyhRZZ.js → composables-OYTD0Y3r.js} +2 -2
  16. package/dist/{composables-TpXyhRZZ.js.map → composables-OYTD0Y3r.js.map} +1 -1
  17. package/dist/index.js +5 -4
  18. package/dist/index.js.map +1 -1
  19. package/dist/install.js +1 -1
  20. package/dist/styles.css +412 -0
  21. package/dist/types/analysisArtifactTypes.d.ts +100 -0
  22. package/dist/types/index.d.ts +2 -0
  23. package/dist/types/index.js +2 -0
  24. package/dist/{useJobsStatusTray-CVecN6Ao.js → useAnalysisArtifacts-Ob8UtVwl.js} +157 -2
  25. package/dist/useAnalysisArtifacts-Ob8UtVwl.js.map +1 -0
  26. package/package.json +1 -1
  27. package/src/__tests__/components/ArtifactSaveDialog.test.ts +516 -0
  28. package/src/__tests__/components/ArtifactSelectorModal.test.ts +178 -0
  29. package/src/__tests__/composables/useAnalysisArtifacts.test.ts +667 -0
  30. package/src/components/ArtifactSaveDialog.story.vue +293 -0
  31. package/src/components/ArtifactSaveDialog.vue +383 -0
  32. package/src/components/ArtifactSelectorModal.story.vue +295 -0
  33. package/src/components/ArtifactSelectorModal.vue +326 -0
  34. package/src/components/index.ts +2 -0
  35. package/src/composables/index.ts +6 -0
  36. package/src/composables/useAnalysisArtifacts.ts +305 -0
  37. package/src/styles/components/artifact-save-dialog.css +17 -0
  38. package/src/styles/components/artifact-selector-modal.css +216 -0
  39. package/src/styles/index.css +2 -0
  40. package/src/types/analysisArtifactTypes.ts +119 -0
  41. package/src/types/index.ts +17 -0
  42. package/dist/components-BQwOeyiy.js.map +0 -1
  43. package/dist/useJobsStatusTray-CVecN6Ao.js.map +0 -1
  44. /package/dist/{ExperimentPopover-8A4Rhffp.js → ExperimentPopover-Ryusjrhv.js} +0 -0
@@ -0,0 +1,293 @@
1
+ <script setup lang="ts">
2
+ import { ref } from 'vue'
3
+ import ArtifactSaveDialog from './ArtifactSaveDialog.vue'
4
+ import {
5
+ ANALYSIS_FILE_ARTIFACT_SCHEMA,
6
+ type AnalysisArtifactDetail,
7
+ type ArtifactSavePayload,
8
+ } from '../types/analysisArtifactTypes'
9
+
10
+ // -- Mock JSON result a plugin would hand the dialog --
11
+ const mockResult: Record<string, unknown> = {
12
+ ic50: 0.42,
13
+ hill_slope: 1.08,
14
+ r_squared: 0.994,
15
+ curves: [{ compound: 'Doxorubicin', points: 8 }],
16
+ }
17
+
18
+ // -- Existing artifacts, used for update mode and collision warnings --
19
+ const existingFileArtifact: AnalysisArtifactDetail = {
20
+ id: 3,
21
+ experiment_id: 42,
22
+ plugin_id: 'drp',
23
+ artifact_key: 'qc-report',
24
+ display_name: 'QC report (PDF)',
25
+ note: 'Generated after plate re-read',
26
+ status: 'active',
27
+ result_keys: ['schema_version', 'kind', 'filename', 'file'],
28
+ result: {
29
+ schema_version: ANALYSIS_FILE_ARTIFACT_SCHEMA,
30
+ kind: 'report',
31
+ filename: 'qc-report.pdf',
32
+ file: { uri: 'mint-object://experiments/42/plugins/drp/analysis-artifacts/qc-report/qc-report.pdf' },
33
+ },
34
+ tree: [],
35
+ summary: null,
36
+ created_at: '2026-07-02T14:00:00Z',
37
+ updated_at: '2026-07-02T14:00:00Z',
38
+ }
39
+
40
+ const existingJsonArtifact: AnalysisArtifactDetail = {
41
+ ...existingFileArtifact,
42
+ id: 1,
43
+ artifact_key: 'dose-response-fit',
44
+ display_name: 'Doxorubicin IC50 fit',
45
+ note: null,
46
+ result_keys: ['ic50', 'curves'],
47
+ result: mockResult,
48
+ }
49
+
50
+ // -- Mock handlers standing in for a generated plugin-client call --
51
+ const lastPayload = ref<ArtifactSavePayload | null>(null)
52
+
53
+ /** Files can't be JSON-serialized; show their name instead. */
54
+ function formatPayload(payload: ArtifactSavePayload): string {
55
+ return JSON.stringify(
56
+ payload,
57
+ (_key, value) => (value instanceof File ? `File(${value.name})` : value),
58
+ 2,
59
+ )
60
+ }
61
+
62
+ function delay(ms: number) {
63
+ return new Promise((resolve) => setTimeout(resolve, ms))
64
+ }
65
+
66
+ async function saveHandler(payload: ArtifactSavePayload) {
67
+ lastPayload.value = payload
68
+ await delay(600)
69
+ return { cleanupPending: payload.mode === 'update' }
70
+ }
71
+
72
+ async function failingHandler(payload: ArtifactSavePayload) {
73
+ lastPayload.value = payload
74
+ await delay(600)
75
+ throw new Error('Plugin backend unavailable (503)')
76
+ }
77
+
78
+ // -- Story state --
79
+ const isOpenFile = ref(false)
80
+ const isOpenJson = ref(false)
81
+ const isOpenBoth = ref(false)
82
+ const isOpenUpdateFile = ref(false)
83
+ const isOpenUpdateJson = ref(false)
84
+ const isOpenCollision = ref(false)
85
+ const isOpenFileConflict = ref(false)
86
+ const isOpenError = ref(false)
87
+ </script>
88
+
89
+ <template>
90
+ <Story title="Experiment/ArtifactSaveDialog">
91
+ <Variant title="Playground">
92
+ <template #default="{ state }">
93
+ <div style="padding: 2rem;">
94
+ <button
95
+ style="padding: 0.5rem 1rem; background: var(--color-primary, #6366F1); color: white; border: none; border-radius: 6px; cursor: pointer; font-size: 0.875rem;"
96
+ @click="isOpenFile = true"
97
+ >
98
+ Open Save Dialog
99
+ </button>
100
+
101
+ <pre
102
+ v-if="lastPayload"
103
+ style="margin-top: 1rem; padding: 0.75rem 1rem; background: var(--bg-secondary, #f8fafc); border: 1px solid var(--border-color, #e2e8f0); border-radius: 6px; font-size: 0.75rem; overflow-x: auto;"
104
+ >{{ formatPayload(lastPayload) }}</pre>
105
+
106
+ <ArtifactSaveDialog
107
+ v-model="isOpenFile"
108
+ :experiment-id="42"
109
+ :mode="state.mode"
110
+ :result="mockResult"
111
+ :default-artifact-key="state.defaultArtifactKey"
112
+ :default-file-kind="state.defaultFileKind"
113
+ :save-handler="saveHandler"
114
+ />
115
+ </div>
116
+ </template>
117
+
118
+ <template #controls="{ state }">
119
+ <HstCheckbox v-model="isOpenFile" title="Open" />
120
+ <HstSelect
121
+ v-model="state.mode"
122
+ title="Mode"
123
+ :options="{ file: 'file', json: 'json', both: 'both' }"
124
+ />
125
+ <HstText v-model="state.defaultArtifactKey" title="Default artifact key" />
126
+ <HstText v-model="state.defaultFileKind" title="Default file kind" />
127
+ </template>
128
+ </Variant>
129
+
130
+ <Variant title="JSON Artifact">
131
+ <div style="padding: 2rem;">
132
+ <p style="font-size: 0.8125rem; color: var(--text-muted, #94a3b8); margin: 0 0 1rem;">
133
+ Metadata form around a programmatic <code>result</code> payload. Handler receives
134
+ <code>kind: 'json', mode: 'create'</code>.
135
+ </p>
136
+ <button
137
+ style="padding: 0.5rem 1rem; background: var(--color-primary, #6366F1); color: white; border: none; border-radius: 6px; cursor: pointer; font-size: 0.875rem;"
138
+ @click="isOpenJson = true"
139
+ >
140
+ Save JSON Artifact
141
+ </button>
142
+
143
+ <ArtifactSaveDialog
144
+ v-model="isOpenJson"
145
+ :experiment-id="42"
146
+ mode="json"
147
+ :result="mockResult"
148
+ default-artifact-key="dose-response-fit"
149
+ :save-handler="saveHandler"
150
+ />
151
+ </div>
152
+ </Variant>
153
+
154
+ <Variant title="Both Kinds">
155
+ <div style="padding: 2rem;">
156
+ <p style="font-size: 0.8125rem; color: var(--text-muted, #94a3b8); margin: 0 0 1rem;">
157
+ Shows the File/JSON toggle; the emitted payload variant follows the selection.
158
+ </p>
159
+ <button
160
+ style="padding: 0.5rem 1rem; background: var(--color-primary, #6366F1); color: white; border: none; border-radius: 6px; cursor: pointer; font-size: 0.875rem;"
161
+ @click="isOpenBoth = true"
162
+ >
163
+ Save Artifact (Both)
164
+ </button>
165
+
166
+ <ArtifactSaveDialog
167
+ v-model="isOpenBoth"
168
+ :experiment-id="42"
169
+ mode="both"
170
+ :result="mockResult"
171
+ :save-handler="saveHandler"
172
+ />
173
+ </div>
174
+ </Variant>
175
+
176
+ <Variant title="Update File Artifact">
177
+ <div style="padding: 2rem;">
178
+ <p style="font-size: 0.8125rem; color: var(--text-muted, #94a3b8); margin: 0 0 1rem;">
179
+ Transactional replacement: key is locked, filename and kind are immutable, and the handler
180
+ receives <code>mode: 'update'</code>. The result reports <code>cleanupPending</code>.
181
+ </p>
182
+ <button
183
+ style="padding: 0.5rem 1rem; background: var(--color-primary, #6366F1); color: white; border: none; border-radius: 6px; cursor: pointer; font-size: 0.875rem;"
184
+ @click="isOpenUpdateFile = true"
185
+ >
186
+ Replace File Artifact
187
+ </button>
188
+
189
+ <ArtifactSaveDialog
190
+ v-model="isOpenUpdateFile"
191
+ :experiment-id="42"
192
+ :existing-artifact="existingFileArtifact"
193
+ accept=".pdf"
194
+ :save-handler="saveHandler"
195
+ />
196
+ </div>
197
+ </Variant>
198
+
199
+ <Variant title="Update JSON Artifact">
200
+ <div style="padding: 2rem;">
201
+ <p style="font-size: 0.8125rem; color: var(--text-muted, #94a3b8); margin: 0 0 1rem;">
202
+ Re-saves a JSON artifact by key (<code>mode: 'upsert'</code>), preserving its display name.
203
+ </p>
204
+ <button
205
+ style="padding: 0.5rem 1rem; background: var(--color-primary, #6366F1); color: white; border: none; border-radius: 6px; cursor: pointer; font-size: 0.875rem;"
206
+ @click="isOpenUpdateJson = true"
207
+ >
208
+ Update JSON Artifact
209
+ </button>
210
+
211
+ <ArtifactSaveDialog
212
+ v-model="isOpenUpdateJson"
213
+ :experiment-id="42"
214
+ :existing-artifact="existingJsonArtifact"
215
+ :result="mockResult"
216
+ :save-handler="saveHandler"
217
+ />
218
+ </div>
219
+ </Variant>
220
+
221
+ <Variant title="Overwrite Warning (JSON)">
222
+ <div style="padding: 2rem;">
223
+ <p style="font-size: 0.8125rem; color: var(--text-muted, #94a3b8); margin: 0 0 1rem;">
224
+ A JSON save with an existing key upserts, so the dialog warns but still allows it. The copy
225
+ states what the backend actually does: results are replaced, name and note are kept unless
226
+ set.
227
+ </p>
228
+ <button
229
+ style="padding: 0.5rem 1rem; background: var(--color-primary, #6366F1); color: white; border: none; border-radius: 6px; cursor: pointer; font-size: 0.875rem;"
230
+ @click="isOpenCollision = true"
231
+ >
232
+ Save With Existing Key
233
+ </button>
234
+
235
+ <ArtifactSaveDialog
236
+ v-model="isOpenCollision"
237
+ :experiment-id="42"
238
+ mode="json"
239
+ :result="mockResult"
240
+ default-artifact-key="dose-response-fit"
241
+ :existing-artifact-keys="['dose-response-fit', 'qc-report']"
242
+ :save-handler="saveHandler"
243
+ />
244
+ </div>
245
+ </Variant>
246
+
247
+ <Variant title="Key Conflict (File)">
248
+ <div style="padding: 2rem;">
249
+ <p style="font-size: 0.8125rem; color: var(--text-muted, #94a3b8); margin: 0 0 1rem;">
250
+ File artifacts are create-only, so a reused key would always conflict backend-side. The
251
+ dialog blocks the save and points at replacement instead of sending a doomed request.
252
+ </p>
253
+ <button
254
+ style="padding: 0.5rem 1rem; background: var(--color-primary, #6366F1); color: white; border: none; border-radius: 6px; cursor: pointer; font-size: 0.875rem;"
255
+ @click="isOpenFileConflict = true"
256
+ >
257
+ Save File With Existing Key
258
+ </button>
259
+
260
+ <ArtifactSaveDialog
261
+ v-model="isOpenFileConflict"
262
+ :experiment-id="42"
263
+ mode="file"
264
+ default-artifact-key="qc-report"
265
+ :existing-artifact-keys="['dose-response-fit', 'qc-report']"
266
+ :save-handler="saveHandler"
267
+ />
268
+ </div>
269
+ </Variant>
270
+
271
+ <Variant title="Save Error">
272
+ <div style="padding: 2rem;">
273
+ <p style="font-size: 0.8125rem; color: var(--text-muted, #94a3b8); margin: 0 0 1rem;">
274
+ The handler rejects; the dialog stays open and renders the error.
275
+ </p>
276
+ <button
277
+ style="padding: 0.5rem 1rem; background: var(--color-primary, #6366F1); color: white; border: none; border-radius: 6px; cursor: pointer; font-size: 0.875rem;"
278
+ @click="isOpenError = true"
279
+ >
280
+ Save (Failing Handler)
281
+ </button>
282
+
283
+ <ArtifactSaveDialog
284
+ v-model="isOpenError"
285
+ :experiment-id="42"
286
+ mode="json"
287
+ :result="mockResult"
288
+ :save-handler="failingHandler"
289
+ />
290
+ </div>
291
+ </Variant>
292
+ </Story>
293
+ </template>
@@ -0,0 +1,383 @@
1
+ <script setup lang="ts">
2
+ /** Save/update dialog for analysis artifacts; delegates the write to a plugin-supplied handler. */
3
+ import { computed, ref, shallowRef, watch } from 'vue'
4
+ import BaseModal from './BaseModal.vue'
5
+ import BaseInput from './BaseInput.vue'
6
+ import BaseTextarea from './BaseTextarea.vue'
7
+ import BaseButton from './BaseButton.vue'
8
+ import AlertBox from './AlertBox.vue'
9
+ import FormField from './FormField.vue'
10
+ import FileUploader from './FileUploader.vue'
11
+ import SegmentedControl from './SegmentedControl.vue'
12
+ import { useRequestSyncState } from '../composables/useRequestSyncState'
13
+ import {
14
+ getInjectedPlatformContext,
15
+ resolveCurrentExperimentId,
16
+ } from '../composables/platformContextHelpers'
17
+ import {
18
+ ANALYSIS_FILE_ARTIFACT_SCHEMA,
19
+ type AnalysisArtifactDetail,
20
+ type ArtifactSaveHandler,
21
+ type ArtifactSavePayload,
22
+ type ArtifactSaveResult,
23
+ } from '../types/analysisArtifactTypes'
24
+ import type { ModalSize } from '../types'
25
+
26
+ interface Props {
27
+ modelValue: boolean
28
+ /** Performs the backend write; the dialog never calls the platform itself. */
29
+ saveHandler: ArtifactSaveHandler
30
+ /** Which artifact kinds the form offers. */
31
+ mode?: 'json' | 'file' | 'both'
32
+ /** JSON result payload supplied programmatically by the host. */
33
+ result?: Record<string, unknown>
34
+ /** Experiment id override; defaults to platform injection or URL. */
35
+ experimentId?: number | null
36
+ /** Existing artifact detail; when set the dialog updates instead of creating. */
37
+ existingArtifact?: AnalysisArtifactDetail | null
38
+ /** Known artifact keys, used to warn (json) or block (file) on collisions. */
39
+ existingArtifactKeys?: string[]
40
+ title?: string
41
+ size?: ModalSize
42
+ defaultArtifactKey?: string
43
+ defaultDisplayName?: string
44
+ /** Maps to the Python `kind=` argument for new file artifacts. */
45
+ defaultFileKind?: string
46
+ /** Forwarded to the file uploader. */
47
+ accept?: string
48
+ /** Forwarded to the file uploader (bytes). */
49
+ maxFileSize?: number
50
+ /**
51
+ * Result metadata for file artifacts. Backend-side this REPLACES the previous
52
+ * metadata; on replacement the existing artifact's metadata is carried through
53
+ * unless this prop supplies a new map.
54
+ */
55
+ metadata?: Record<string, unknown>
56
+ }
57
+
58
+ const props = withDefaults(defineProps<Props>(), {
59
+ mode: 'file',
60
+ experimentId: null,
61
+ existingArtifact: null,
62
+ size: 'md',
63
+ defaultArtifactKey: 'default',
64
+ defaultFileKind: 'file',
65
+ })
66
+
67
+ const emit = defineEmits<{
68
+ 'update:modelValue': [value: boolean]
69
+ saved: [result: ArtifactSaveResult & { payload: ArtifactSavePayload }]
70
+ error: [message: string]
71
+ cancel: []
72
+ }>()
73
+
74
+ const ARTIFACT_KEY_PATTERN = /^[a-z0-9][a-z0-9._-]*$/
75
+
76
+ const injectedContext = getInjectedPlatformContext()
77
+ const request = useRequestSyncState('Failed to save analysis artifact.')
78
+
79
+ const isUpdate = computed(() => props.existingArtifact !== null)
80
+ const existingResult = computed(
81
+ () => (props.existingArtifact?.result ?? {}) as Record<string, unknown>,
82
+ )
83
+ const existingIsFile = computed(
84
+ () => existingResult.value.schema_version === ANALYSIS_FILE_ARTIFACT_SCHEMA,
85
+ )
86
+ const existingFileKind = computed(() =>
87
+ typeof existingResult.value.kind === 'string' ? existingResult.value.kind : undefined)
88
+ const existingFilename = computed(() =>
89
+ typeof existingResult.value.filename === 'string' ? existingResult.value.filename : undefined)
90
+ const existingMetadata = computed(() => {
91
+ const value = existingResult.value.metadata
92
+ return value && typeof value === 'object' && !Array.isArray(value)
93
+ ? value as Record<string, unknown>
94
+ : undefined
95
+ })
96
+
97
+ const activeKind = ref<'json' | 'file'>('file')
98
+ const keyInput = ref('')
99
+ const displayNameInput = ref('')
100
+ const noteInput = ref('')
101
+ const selectedFile = shallowRef<File | null>(null)
102
+ let initialNote = ''
103
+
104
+ function resetForm(): void {
105
+ const existing = props.existingArtifact
106
+ activeKind.value = existing
107
+ ? (existingIsFile.value ? 'file' : 'json')
108
+ : (props.mode === 'json' ? 'json' : 'file')
109
+ keyInput.value = existing?.artifact_key ?? props.defaultArtifactKey
110
+ displayNameInput.value = existing?.display_name ?? props.defaultDisplayName ?? ''
111
+ initialNote = existing?.note ?? ''
112
+ noteInput.value = initialNote
113
+ selectedFile.value = null
114
+ request.clearError()
115
+ }
116
+
117
+ resetForm()
118
+ watch(() => props.modelValue, (open) => {
119
+ if (open) resetForm()
120
+ })
121
+ // Key off identity, not object reference: re-fetching the same artifact must not
122
+ // wipe in-progress edits.
123
+ watch(
124
+ () => (props.existingArtifact ? `${props.existingArtifact.id}:${props.existingArtifact.artifact_key}` : null),
125
+ resetForm,
126
+ )
127
+
128
+ const resolvedTitle = computed(() =>
129
+ props.title ?? (isUpdate.value ? 'Update Analysis Artifact' : 'Save Analysis Artifact'))
130
+ const showKindToggle = computed(() => !isUpdate.value && props.mode === 'both')
131
+ const showFileInput = computed(() => activeKind.value === 'file')
132
+ // save_analysis_artifact ignores note=None (it preserves), so a JSON artifact's
133
+ // note cannot be cleared through the save path — that belongs to updateMetadata.
134
+ // update_analysis_file_artifact does honour the not-provided/null/value sentinel.
135
+ const showNoteField = computed(() => !isUpdate.value || existingIsFile.value)
136
+
137
+ const effectiveKey = computed(() => {
138
+ if (isUpdate.value) return props.existingArtifact?.artifact_key ?? ''
139
+ return keyInput.value.trim() || props.defaultArtifactKey
140
+ })
141
+ const keyValid = computed(() => ARTIFACT_KEY_PATTERN.test(effectiveKey.value))
142
+ const keyExists = computed(
143
+ () => !isUpdate.value && (props.existingArtifactKeys ?? []).includes(effectiveKey.value),
144
+ )
145
+ // A file artifact is create-only: reusing a key always fails backend-side, and the
146
+ // dialog has no detail to replace it safely. JSON keys upsert natively, so warn only.
147
+ const keyBlocked = computed(() => keyExists.value && activeKind.value === 'file')
148
+ const keyError = computed(() => {
149
+ if (!keyValid.value) return 'Use lowercase letters, digits, dots, dashes, or underscores.'
150
+ if (keyBlocked.value) return 'This key is already used. Choose another, or replace the existing artifact.'
151
+ return undefined
152
+ })
153
+
154
+ const canSubmit = computed(() => {
155
+ if (request.loading.value || !keyValid.value || keyBlocked.value) return false
156
+ if (activeKind.value === 'file') return selectedFile.value !== null
157
+ return props.result !== undefined
158
+ })
159
+
160
+ function resolveExperimentId(): number | undefined {
161
+ return props.experimentId ?? resolveCurrentExperimentId(injectedContext)
162
+ }
163
+
164
+ /** Set-only note for creates and JSON upserts: an empty field just sends nothing. */
165
+ function createNotePayload(): { note?: string } {
166
+ const value = noteInput.value.trim()
167
+ return showNoteField.value && value ? { note: value } : {}
168
+ }
169
+
170
+ /**
171
+ * Three-state note, honoured only by update_analysis_file_artifact:
172
+ * omitted = preserve, null = clear, string = set.
173
+ */
174
+ function updateNotePayload(): { note?: string | null } {
175
+ if (noteInput.value === initialNote) return {}
176
+ const value = noteInput.value.trim()
177
+ return value ? { note: value } : { note: null }
178
+ }
179
+
180
+ function buildPayload(experimentId: number): ArtifactSavePayload | null {
181
+ const base = {
182
+ experimentId,
183
+ artifactKey: effectiveKey.value,
184
+ }
185
+ if (activeKind.value === 'json') {
186
+ if (props.result === undefined) return null
187
+ // Upsert only sets display_name when provided; pass the existing one through
188
+ // on update so a re-save never blanks it.
189
+ const displayName = isUpdate.value
190
+ ? props.existingArtifact?.display_name
191
+ : displayNameInput.value.trim() || undefined
192
+ return {
193
+ kind: 'json',
194
+ mode: isUpdate.value || keyExists.value ? 'upsert' : 'create',
195
+ ...base,
196
+ ...createNotePayload(),
197
+ ...(displayName ? { displayName } : {}),
198
+ result: props.result,
199
+ }
200
+ }
201
+ const file = selectedFile.value
202
+ if (!file) return null
203
+ if (isUpdate.value) {
204
+ // Metadata replaces rather than merges backend-side, so send the full intended
205
+ // map: the caller's override, else whatever the artifact already carries.
206
+ const metadata = props.metadata ?? existingMetadata.value
207
+ return {
208
+ kind: 'file',
209
+ mode: 'update',
210
+ ...base,
211
+ ...updateNotePayload(),
212
+ file,
213
+ // kind is immutable backend-side; always echo the artifact's own kind.
214
+ fileKind: existingFileKind.value ?? props.defaultFileKind,
215
+ ...(metadata ? { metadata } : {}),
216
+ ...(file.type ? { contentType: file.type } : {}),
217
+ }
218
+ }
219
+ const displayName = displayNameInput.value.trim()
220
+ return {
221
+ kind: 'file',
222
+ mode: 'create',
223
+ ...base,
224
+ ...createNotePayload(),
225
+ file,
226
+ fileKind: props.defaultFileKind,
227
+ filename: file.name,
228
+ ...(displayName ? { displayName } : {}),
229
+ ...(props.metadata ? { metadata: props.metadata } : {}),
230
+ ...(file.type ? { contentType: file.type } : {}),
231
+ }
232
+ }
233
+
234
+ function handleFileUpload(files: File[]): void {
235
+ selectedFile.value = files[0] ?? null
236
+ }
237
+
238
+ function handleFileError(message: string): void {
239
+ request.setError(message)
240
+ }
241
+
242
+ async function handleSubmit(): Promise<void> {
243
+ if (!canSubmit.value) return
244
+ const experimentId = resolveExperimentId()
245
+ if (experimentId === undefined) {
246
+ const message = request.setError('No experiment is available for saving an artifact.')
247
+ emit('error', message)
248
+ return
249
+ }
250
+ const payload = buildPayload(experimentId)
251
+ if (!payload) return
252
+ try {
253
+ const result = await request.run(
254
+ () => props.saveHandler(payload),
255
+ { success: 'save', errorMessage: 'Failed to save analysis artifact.' },
256
+ )
257
+ emit('saved', { ...(result ?? {}), payload })
258
+ emit('update:modelValue', false)
259
+ resetForm()
260
+ } catch {
261
+ emit('error', request.error.value ?? 'Failed to save analysis artifact.')
262
+ }
263
+ }
264
+
265
+ function handleCancel(): void {
266
+ emit('cancel')
267
+ emit('update:modelValue', false)
268
+ }
269
+ </script>
270
+
271
+ <template>
272
+ <BaseModal
273
+ :model-value="modelValue"
274
+ :title="resolvedTitle"
275
+ :size="size"
276
+ :closable="!request.loading.value"
277
+ @update:model-value="emit('update:modelValue', $event)"
278
+ @close="emit('cancel')"
279
+ >
280
+ <div class="mint-artifact-save-dialog">
281
+ <SegmentedControl
282
+ v-if="showKindToggle"
283
+ v-model="activeKind"
284
+ class="mint-artifact-save-dialog__kind-toggle"
285
+ :options="[
286
+ { value: 'file', label: 'File' },
287
+ { value: 'json', label: 'JSON' },
288
+ ]"
289
+ full-width
290
+ />
291
+
292
+ <AlertBox
293
+ v-if="keyExists && !keyBlocked"
294
+ type="warning"
295
+ class="mint-artifact-save-dialog__warning"
296
+ >
297
+ An artifact with key "{{ effectiveKey }}" already exists. Its results will be
298
+ replaced; its name and note are kept unless you set new ones.
299
+ </AlertBox>
300
+
301
+ <FormField
302
+ label="Artifact key"
303
+ class="mint-artifact-save-dialog__key"
304
+ :error="keyError"
305
+ :hint="isUpdate ? 'Keys are fixed once an artifact exists.' : undefined"
306
+ >
307
+ <BaseInput
308
+ v-model="keyInput"
309
+ :readonly="isUpdate"
310
+ :error="Boolean(keyError)"
311
+ :placeholder="defaultArtifactKey"
312
+ />
313
+ </FormField>
314
+
315
+ <FormField
316
+ v-if="!isUpdate"
317
+ label="Display name"
318
+ class="mint-artifact-save-dialog__name"
319
+ >
320
+ <BaseInput
321
+ v-model="displayNameInput"
322
+ placeholder="Shown in artifact lists"
323
+ />
324
+ </FormField>
325
+
326
+ <FormField
327
+ v-if="showNoteField"
328
+ label="Note"
329
+ class="mint-artifact-save-dialog__note"
330
+ >
331
+ <BaseTextarea
332
+ v-model="noteInput"
333
+ placeholder="Optional context for this artifact"
334
+ />
335
+ </FormField>
336
+
337
+ <template v-if="showFileInput">
338
+ <FileUploader
339
+ class="mint-artifact-save-dialog__file"
340
+ :accept="accept"
341
+ :max-size="maxFileSize"
342
+ :multiple="false"
343
+ @upload="handleFileUpload"
344
+ @error="handleFileError"
345
+ />
346
+ <p
347
+ v-if="isUpdate && existingFilename"
348
+ class="mint-artifact-save-dialog__immutable"
349
+ >
350
+ Replaces the content of <code>{{ existingFilename }}</code> (kind
351
+ <code>{{ existingFileKind ?? defaultFileKind }}</code>); filename and kind stay unchanged.
352
+ </p>
353
+ </template>
354
+
355
+ <AlertBox
356
+ v-if="request.error.value"
357
+ type="error"
358
+ class="mint-artifact-save-dialog__error"
359
+ role="alert"
360
+ >
361
+ {{ request.error.value }}
362
+ </AlertBox>
363
+ </div>
364
+
365
+ <template #footer>
366
+ <BaseButton variant="secondary" :disabled="request.loading.value" @click="handleCancel">
367
+ Cancel
368
+ </BaseButton>
369
+ <BaseButton
370
+ class="mint-artifact-save-dialog__submit"
371
+ :loading="request.loading.value"
372
+ :disabled="!canSubmit"
373
+ @click="handleSubmit"
374
+ >
375
+ {{ isUpdate ? 'Update Artifact' : 'Save Artifact' }}
376
+ </BaseButton>
377
+ </template>
378
+ </BaseModal>
379
+ </template>
380
+
381
+ <style>
382
+ @import '../styles/components/artifact-save-dialog.css';
383
+ </style>