@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.
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 +59 -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-CpnOaPbP.js} +1774 -1144
  11. package/dist/components-CpnOaPbP.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 +133 -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 +566 -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 +319 -0
  31. package/src/components/ArtifactSaveDialog.vue +434 -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 +167 -0
  41. package/src/types/index.ts +24 -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,434 @@
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 ArtifactFileSource,
21
+ type ArtifactSaveDialogHandler,
22
+ type ArtifactSaveDialogPayload,
23
+ type ArtifactSaveHandler,
24
+ type ArtifactSaveResult,
25
+ type HandlerArtifactSaveHandler,
26
+ } from '../types/analysisArtifactTypes'
27
+ import type { ModalSize } from '../types'
28
+
29
+ interface Props {
30
+ modelValue: boolean
31
+ /** Performs the backend write; the dialog never calls the platform itself. */
32
+ saveHandler: ArtifactSaveDialogHandler
33
+ /** Which artifact kinds the form offers. */
34
+ mode?: 'json' | 'file' | 'both'
35
+ /** Use `handler` when the plugin backend serializes the file content itself. */
36
+ fileSource?: ArtifactFileSource
37
+ /** JSON result payload supplied programmatically by the host. */
38
+ result?: Record<string, unknown>
39
+ /** Experiment id override; defaults to platform injection or URL. */
40
+ experimentId?: number | null
41
+ /** Existing artifact detail; when set the dialog updates instead of creating. */
42
+ existingArtifact?: AnalysisArtifactDetail | null
43
+ /** Known artifact keys, used to warn (json) or block (file) on collisions. */
44
+ existingArtifactKeys?: string[]
45
+ title?: string
46
+ size?: ModalSize
47
+ defaultArtifactKey?: string
48
+ defaultDisplayName?: string
49
+ /** Maps to the Python `kind=` argument for new file artifacts. */
50
+ defaultFileKind?: string
51
+ /** Forwarded to the file uploader. */
52
+ accept?: string
53
+ /** Forwarded to the file uploader (bytes). */
54
+ maxFileSize?: number
55
+ /**
56
+ * Result metadata for file artifacts. Backend-side this REPLACES the previous
57
+ * metadata; on replacement the existing artifact's metadata is carried through
58
+ * unless this prop supplies a new map.
59
+ */
60
+ metadata?: Record<string, unknown>
61
+ }
62
+
63
+ const props = withDefaults(defineProps<Props>(), {
64
+ mode: 'file',
65
+ fileSource: 'upload',
66
+ experimentId: null,
67
+ existingArtifact: null,
68
+ size: 'md',
69
+ defaultArtifactKey: 'default',
70
+ defaultFileKind: 'file',
71
+ })
72
+
73
+ const emit = defineEmits<{
74
+ 'update:modelValue': [value: boolean]
75
+ saved: [result: ArtifactSaveResult & { payload: ArtifactSaveDialogPayload }]
76
+ error: [message: string]
77
+ cancel: []
78
+ }>()
79
+
80
+ const ARTIFACT_KEY_PATTERN = /^[a-z0-9][a-z0-9._-]*$/
81
+
82
+ const injectedContext = getInjectedPlatformContext()
83
+ const request = useRequestSyncState('Failed to save analysis artifact.')
84
+
85
+ const isUpdate = computed(() => props.existingArtifact !== null)
86
+ const existingResult = computed(
87
+ () => (props.existingArtifact?.result ?? {}) as Record<string, unknown>,
88
+ )
89
+ const existingIsFile = computed(
90
+ () => existingResult.value.schema_version === ANALYSIS_FILE_ARTIFACT_SCHEMA,
91
+ )
92
+ const existingFileKind = computed(() =>
93
+ typeof existingResult.value.kind === 'string' ? existingResult.value.kind : undefined)
94
+ const existingFilename = computed(() =>
95
+ typeof existingResult.value.filename === 'string' ? existingResult.value.filename : undefined)
96
+ const existingMetadata = computed(() => {
97
+ const value = existingResult.value.metadata
98
+ return value && typeof value === 'object' && !Array.isArray(value)
99
+ ? value as Record<string, unknown>
100
+ : undefined
101
+ })
102
+
103
+ const activeKind = ref<'json' | 'file'>('file')
104
+ const keyInput = ref('')
105
+ const displayNameInput = ref('')
106
+ const noteInput = ref('')
107
+ const selectedFile = shallowRef<File | null>(null)
108
+ let initialNote = ''
109
+
110
+ function resetForm(): void {
111
+ const existing = props.existingArtifact
112
+ activeKind.value = existing
113
+ ? (existingIsFile.value ? 'file' : 'json')
114
+ : (props.mode === 'json' ? 'json' : 'file')
115
+ keyInput.value = existing?.artifact_key ?? props.defaultArtifactKey
116
+ displayNameInput.value = existing?.display_name ?? props.defaultDisplayName ?? ''
117
+ initialNote = existing?.note ?? ''
118
+ noteInput.value = initialNote
119
+ selectedFile.value = null
120
+ request.clearError()
121
+ }
122
+
123
+ resetForm()
124
+ watch(() => props.modelValue, (open) => {
125
+ if (open) resetForm()
126
+ })
127
+ // Key off identity, not object reference: re-fetching the same artifact must not
128
+ // wipe in-progress edits.
129
+ watch(
130
+ () => (props.existingArtifact ? `${props.existingArtifact.id}:${props.existingArtifact.artifact_key}` : null),
131
+ resetForm,
132
+ )
133
+
134
+ const resolvedTitle = computed(() =>
135
+ props.title ?? (isUpdate.value ? 'Update Analysis Artifact' : 'Save Analysis Artifact'))
136
+ const showKindToggle = computed(() => !isUpdate.value && props.mode === 'both')
137
+ const showFileUploader = computed(
138
+ () => activeKind.value === 'file' && props.fileSource === 'upload',
139
+ )
140
+ // save_analysis_artifact ignores note=None (it preserves), so a JSON artifact's
141
+ // note cannot be cleared through the save path — that belongs to updateMetadata.
142
+ // update_analysis_file_artifact does honour the not-provided/null/value sentinel.
143
+ const showNoteField = computed(() => !isUpdate.value || existingIsFile.value)
144
+
145
+ const effectiveKey = computed(() => {
146
+ if (isUpdate.value) return props.existingArtifact?.artifact_key ?? ''
147
+ return keyInput.value.trim() || props.defaultArtifactKey
148
+ })
149
+ const keyValid = computed(() => ARTIFACT_KEY_PATTERN.test(effectiveKey.value))
150
+ const keyExists = computed(
151
+ () => !isUpdate.value && (props.existingArtifactKeys ?? []).includes(effectiveKey.value),
152
+ )
153
+ // A file artifact is create-only: reusing a key always fails backend-side, and the
154
+ // dialog has no detail to replace it safely. JSON keys upsert natively, so warn only.
155
+ const keyBlocked = computed(() => keyExists.value && activeKind.value === 'file')
156
+ const keyError = computed(() => {
157
+ if (!keyValid.value) return 'Use lowercase letters, digits, dots, dashes, or underscores.'
158
+ if (keyBlocked.value) return 'This key is already used. Choose another, or replace the existing artifact.'
159
+ return undefined
160
+ })
161
+
162
+ const canSubmit = computed(() => {
163
+ if (request.loading.value || !keyValid.value || keyBlocked.value) return false
164
+ if (activeKind.value === 'file') {
165
+ return props.fileSource === 'handler' || selectedFile.value !== null
166
+ }
167
+ return props.result !== undefined
168
+ })
169
+
170
+ function resolveExperimentId(): number | undefined {
171
+ return props.experimentId ?? resolveCurrentExperimentId(injectedContext)
172
+ }
173
+
174
+ /** Set-only note for creates and JSON upserts: an empty field just sends nothing. */
175
+ function createNotePayload(): { note?: string } {
176
+ const value = noteInput.value.trim()
177
+ return showNoteField.value && value ? { note: value } : {}
178
+ }
179
+
180
+ /**
181
+ * Three-state note, honoured only by update_analysis_file_artifact:
182
+ * omitted = preserve, null = clear, string = set.
183
+ */
184
+ function updateNotePayload(): { note?: string | null } {
185
+ if (noteInput.value === initialNote) return {}
186
+ const value = noteInput.value.trim()
187
+ return value ? { note: value } : { note: null }
188
+ }
189
+
190
+ function buildPayload(experimentId: number): ArtifactSaveDialogPayload | null {
191
+ const base = {
192
+ experimentId,
193
+ artifactKey: effectiveKey.value,
194
+ }
195
+ if (activeKind.value === 'json') {
196
+ if (props.result === undefined) return null
197
+ // Upsert only sets display_name when provided; pass the existing one through
198
+ // on update so a re-save never blanks it.
199
+ const displayName = isUpdate.value
200
+ ? props.existingArtifact?.display_name
201
+ : displayNameInput.value.trim() || undefined
202
+ return {
203
+ kind: 'json',
204
+ mode: isUpdate.value || keyExists.value ? 'upsert' : 'create',
205
+ ...base,
206
+ ...createNotePayload(),
207
+ ...(displayName ? { displayName } : {}),
208
+ result: props.result,
209
+ }
210
+ }
211
+ if (isUpdate.value) {
212
+ // Metadata replaces rather than merges backend-side, so send the full intended
213
+ // map: the caller's override, else whatever the artifact already carries.
214
+ const metadata = props.metadata ?? existingMetadata.value
215
+ if (props.fileSource === 'handler') {
216
+ return {
217
+ kind: 'file',
218
+ source: 'handler',
219
+ mode: 'update',
220
+ ...base,
221
+ ...updateNotePayload(),
222
+ fileKind: existingFileKind.value ?? props.defaultFileKind,
223
+ ...(metadata ? { metadata } : {}),
224
+ }
225
+ }
226
+ const file = selectedFile.value
227
+ if (!file) return null
228
+ return {
229
+ kind: 'file',
230
+ mode: 'update',
231
+ ...base,
232
+ ...updateNotePayload(),
233
+ file,
234
+ // kind is immutable backend-side; always echo the artifact's own kind.
235
+ fileKind: existingFileKind.value ?? props.defaultFileKind,
236
+ ...(metadata ? { metadata } : {}),
237
+ ...(file.type ? { contentType: file.type } : {}),
238
+ }
239
+ }
240
+ const displayName = displayNameInput.value.trim()
241
+ if (props.fileSource === 'handler') {
242
+ return {
243
+ kind: 'file',
244
+ source: 'handler',
245
+ mode: 'create',
246
+ ...base,
247
+ ...createNotePayload(),
248
+ fileKind: props.defaultFileKind,
249
+ ...(displayName ? { displayName } : {}),
250
+ ...(props.metadata ? { metadata: props.metadata } : {}),
251
+ }
252
+ }
253
+ const file = selectedFile.value
254
+ if (!file) return null
255
+ return {
256
+ kind: 'file',
257
+ mode: 'create',
258
+ ...base,
259
+ ...createNotePayload(),
260
+ file,
261
+ fileKind: props.defaultFileKind,
262
+ filename: file.name,
263
+ ...(displayName ? { displayName } : {}),
264
+ ...(props.metadata ? { metadata: props.metadata } : {}),
265
+ ...(file.type ? { contentType: file.type } : {}),
266
+ }
267
+ }
268
+
269
+ function runSaveHandler(
270
+ payload: ArtifactSaveDialogPayload,
271
+ ): Promise<ArtifactSaveResult | void> {
272
+ if (props.fileSource === 'handler') {
273
+ if (payload.kind === 'file' && !('source' in payload)) {
274
+ throw new Error('ArtifactSaveDialog built an upload payload for a handler-owned file.')
275
+ }
276
+ return (props.saveHandler as HandlerArtifactSaveHandler)(payload)
277
+ }
278
+ if (payload.kind === 'file' && 'source' in payload) {
279
+ throw new Error('ArtifactSaveDialog built a handler-owned payload for an uploaded file.')
280
+ }
281
+ return (props.saveHandler as ArtifactSaveHandler)(payload)
282
+ }
283
+
284
+ function handleFileUpload(files: File[]): void {
285
+ selectedFile.value = files[0] ?? null
286
+ }
287
+
288
+ function handleFileError(message: string): void {
289
+ request.setError(message)
290
+ }
291
+
292
+ async function handleSubmit(): Promise<void> {
293
+ if (!canSubmit.value) return
294
+ const experimentId = resolveExperimentId()
295
+ if (experimentId === undefined) {
296
+ const message = request.setError('No experiment is available for saving an artifact.')
297
+ emit('error', message)
298
+ return
299
+ }
300
+ const payload = buildPayload(experimentId)
301
+ if (!payload) return
302
+ try {
303
+ const result = await request.run(
304
+ () => runSaveHandler(payload),
305
+ { success: 'save', errorMessage: 'Failed to save analysis artifact.' },
306
+ )
307
+ emit('saved', { ...(result ?? {}), payload })
308
+ emit('update:modelValue', false)
309
+ resetForm()
310
+ } catch {
311
+ emit('error', request.error.value ?? 'Failed to save analysis artifact.')
312
+ }
313
+ }
314
+
315
+ function handleCancel(): void {
316
+ emit('cancel')
317
+ emit('update:modelValue', false)
318
+ }
319
+ </script>
320
+
321
+ <template>
322
+ <BaseModal
323
+ :model-value="modelValue"
324
+ :title="resolvedTitle"
325
+ :size="size"
326
+ :closable="!request.loading.value"
327
+ @update:model-value="emit('update:modelValue', $event)"
328
+ @close="emit('cancel')"
329
+ >
330
+ <div class="mint-artifact-save-dialog">
331
+ <SegmentedControl
332
+ v-if="showKindToggle"
333
+ v-model="activeKind"
334
+ class="mint-artifact-save-dialog__kind-toggle"
335
+ :options="[
336
+ { value: 'file', label: 'File' },
337
+ { value: 'json', label: 'JSON' },
338
+ ]"
339
+ full-width
340
+ />
341
+
342
+ <AlertBox
343
+ v-if="keyExists && !keyBlocked"
344
+ type="warning"
345
+ class="mint-artifact-save-dialog__warning"
346
+ >
347
+ An artifact with key "{{ effectiveKey }}" already exists. Its results will be
348
+ replaced; its name and note are kept unless you set new ones.
349
+ </AlertBox>
350
+
351
+ <FormField
352
+ label="Artifact key"
353
+ class="mint-artifact-save-dialog__key"
354
+ :error="keyError"
355
+ :hint="isUpdate ? 'Keys are fixed once an artifact exists.' : undefined"
356
+ >
357
+ <BaseInput
358
+ v-model="keyInput"
359
+ :readonly="isUpdate"
360
+ :error="Boolean(keyError)"
361
+ :placeholder="defaultArtifactKey"
362
+ />
363
+ </FormField>
364
+
365
+ <FormField
366
+ v-if="!isUpdate"
367
+ label="Display name"
368
+ class="mint-artifact-save-dialog__name"
369
+ >
370
+ <BaseInput
371
+ v-model="displayNameInput"
372
+ placeholder="Shown in artifact lists"
373
+ />
374
+ </FormField>
375
+
376
+ <FormField
377
+ v-if="showNoteField"
378
+ label="Note"
379
+ class="mint-artifact-save-dialog__note"
380
+ >
381
+ <BaseTextarea
382
+ v-model="noteInput"
383
+ placeholder="Optional context for this artifact"
384
+ />
385
+ </FormField>
386
+
387
+ <template v-if="activeKind === 'file'">
388
+ <FileUploader
389
+ v-if="showFileUploader"
390
+ class="mint-artifact-save-dialog__file"
391
+ :accept="accept"
392
+ :max-size="maxFileSize"
393
+ :multiple="false"
394
+ @upload="handleFileUpload"
395
+ @error="handleFileError"
396
+ />
397
+ <p
398
+ v-if="isUpdate && existingFilename"
399
+ class="mint-artifact-save-dialog__immutable"
400
+ >
401
+ Replaces the content of <code>{{ existingFilename }}</code> (kind
402
+ <code>{{ existingFileKind ?? defaultFileKind }}</code>); filename and kind stay unchanged.
403
+ </p>
404
+ </template>
405
+
406
+ <AlertBox
407
+ v-if="request.error.value"
408
+ type="error"
409
+ class="mint-artifact-save-dialog__error"
410
+ role="alert"
411
+ >
412
+ {{ request.error.value }}
413
+ </AlertBox>
414
+ </div>
415
+
416
+ <template #footer>
417
+ <BaseButton variant="secondary" :disabled="request.loading.value" @click="handleCancel">
418
+ Cancel
419
+ </BaseButton>
420
+ <BaseButton
421
+ class="mint-artifact-save-dialog__submit"
422
+ :loading="request.loading.value"
423
+ :disabled="!canSubmit"
424
+ @click="handleSubmit"
425
+ >
426
+ {{ isUpdate ? 'Update Artifact' : 'Save Artifact' }}
427
+ </BaseButton>
428
+ </template>
429
+ </BaseModal>
430
+ </template>
431
+
432
+ <style>
433
+ @import '../styles/components/artifact-save-dialog.css';
434
+ </style>