@morscherlab/mint-sdk 1.1.0 → 1.1.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/{EmptyState-DgBRoYAy.js → EmptyState-LMPS3keb.js} +3 -193
- package/dist/EmptyState-LMPS3keb.js.map +1 -0
- package/dist/{ExperimentPopover-BFw3QX8L.js → ExperimentPopover-D6_RGzTY.js} +113 -35
- package/dist/ExperimentPopover-D6_RGzTY.js.map +1 -0
- package/dist/{ExperimentSelectorModal-BBWXC7KW.js → ExperimentSelectorModal-CDNsNudn.js} +9 -11
- package/dist/ExperimentSelectorModal-CDNsNudn.js.map +1 -0
- package/dist/{SettingsModal-Dpg-X1OM.js → SettingsModal-BGBimSIA.js} +5 -5
- package/dist/{SettingsModal-Dpg-X1OM.js.map → SettingsModal-BGBimSIA.js.map} +1 -1
- package/dist/__stories__/experiment-helpers.d.ts +3 -0
- package/dist/__tests__/stores/experiment.test.d.ts +1 -0
- package/dist/{analysisArtifactTypes-ejqR8MX3.js → analysisArtifactTypes-DQ0MjwHZ.js} +1 -1
- package/dist/{analysisArtifactTypes-ejqR8MX3.js.map → analysisArtifactTypes-DQ0MjwHZ.js.map} +1 -1
- package/dist/components/ExperimentPopover.vue.d.ts +5 -0
- package/dist/components/ExperimentSelectorModal.vue.d.ts +1 -5
- package/dist/components/PluginWorkspaceView.props.d.ts +1 -4
- package/dist/components/PluginWorkspaceView.vue.d.ts +0 -1
- package/dist/components/SmartGroupFieldRecipe.vue.d.ts +2 -2
- package/dist/components/index.js +7 -7
- package/dist/{components-Ci_BTx00.js → components-BbOyjE2T.js} +24 -29
- package/dist/components-BbOyjE2T.js.map +1 -0
- package/dist/composables/experiment-utils.d.ts +10 -0
- package/dist/composables/index.d.ts +2 -2
- package/dist/composables/index.js +11 -10
- package/dist/composables/useAppExperiment.d.ts +29 -17
- package/dist/composables/usePlatformContext.d.ts +3 -3
- package/dist/{composables-C0VEJ73O.js → composables-CW8NRXLB.js} +5 -5
- package/dist/{composables-C0VEJ73O.js.map → composables-CW8NRXLB.js.map} +1 -1
- package/dist/experiment-Y9Iz7KYi.js +95 -0
- package/dist/experiment-Y9Iz7KYi.js.map +1 -0
- package/dist/{experiment-utils-CxkruZ3G.js → experiment-utils-7Yw-h0tD.js} +16 -2
- package/dist/{experiment-utils-CxkruZ3G.js.map → experiment-utils-7Yw-h0tD.js.map} +1 -1
- package/dist/index.js +16 -15
- package/dist/index.js.map +1 -1
- package/dist/install.js +7 -7
- package/dist/{plotly.min-BR53hja4.js → plotly.min-c2Sez60c.js} +1 -1
- package/dist/{plotly.min-BR53hja4.js.map → plotly.min-c2Sez60c.js.map} +1 -1
- package/dist/stores/auth.d.ts +6 -6
- package/dist/stores/experiment.d.ts +166 -0
- package/dist/stores/index.d.ts +1 -0
- package/dist/stores/index.js +3 -2
- package/dist/styles.css +188 -0
- package/dist/templates/index.js +4 -4
- package/dist/{templates-Cdj93S9C.js → templates-CJRFiWUX.js} +2 -2
- package/dist/{templates-Cdj93S9C.js.map → templates-CJRFiWUX.js.map} +1 -1
- package/dist/types/auth.d.ts +1 -1
- package/dist/types/index.js +1 -1
- package/dist/types/platform.d.ts +1 -1
- package/dist/{auth-B87xM--s.js → useApi-CDBq6z23.js} +191 -2
- package/dist/useApi-CDBq6z23.js.map +1 -0
- package/dist/{useControlSchema-B_qgU4rW.js → useControlSchema-BEAgLyyx.js} +5 -4
- package/dist/{useControlSchema-B_qgU4rW.js.map → useControlSchema-BEAgLyyx.js.map} +1 -1
- package/dist/{useDropdownState-gVaMXtwW.js → useDropdownState-Be-ug_ci.js} +2 -2
- package/dist/{useDropdownState-gVaMXtwW.js.map → useDropdownState-Be-ug_ci.js.map} +1 -1
- package/dist/{useExperimentSelector-BuQZRgMt.js → useExperimentSelector-Dld4841S.js} +4 -3
- package/dist/{useExperimentSelector-BuQZRgMt.js.map → useExperimentSelector-Dld4841S.js.map} +1 -1
- package/dist/{useFormBuilder-D1G5J5Z5.js → useFormBuilder-CR9pBApW.js} +2 -2
- package/dist/{useFormBuilder-D1G5J5Z5.js.map → useFormBuilder-CR9pBApW.js.map} +1 -1
- package/dist/{useProtocolTemplates-DeaOmMaw.js → useProtocolTemplates-DXeIHMwV.js} +38 -42
- package/dist/useProtocolTemplates-DXeIHMwV.js.map +1 -0
- package/package.json +1 -1
- package/src/__stories__/experiment-helpers.ts +12 -9
- package/src/__tests__/components/AppTopBar.test.ts +10 -6
- package/src/__tests__/components/ExperimentPopover.test.ts +64 -0
- package/src/__tests__/components/ExperimentSelectorModal.test.ts +86 -41
- package/src/__tests__/components/PluginWorkspaceView.test.ts +103 -19
- package/src/__tests__/components/SettingsModal.test.ts +1 -1
- package/src/__tests__/composables/experiment-utils.test.ts +33 -0
- package/src/__tests__/composables/useAppExperiment.test.ts +331 -92
- package/src/__tests__/composables/useAuth.test.ts +2 -2
- package/src/__tests__/composables/usePlatformContext.test.ts +5 -1
- package/src/__tests__/stores/experiment.test.ts +300 -0
- package/src/components/AppTopBar.vue +0 -2
- package/src/components/ExperimentPopover.story.vue +59 -0
- package/src/components/ExperimentPopover.vue +96 -6
- package/src/components/ExperimentSelectorModal.story.vue +6 -16
- package/src/components/ExperimentSelectorModal.vue +8 -4
- package/src/components/PluginWorkspaceView.props.ts +1 -4
- package/src/components/PluginWorkspaceView.story.vue +12 -5
- package/src/components/PluginWorkspaceView.vue +0 -2
- package/src/composables/experiment-utils.ts +22 -0
- package/src/composables/index.ts +1 -2
- package/src/composables/useAppExperiment.ts +57 -67
- package/src/composables/useAuth.ts +1 -1
- package/src/stores/experiment.ts +120 -0
- package/src/stores/index.ts +1 -0
- package/src/styles/components/experiment-popover.css +105 -0
- package/src/types/auth.ts +1 -1
- package/src/types/platform.ts +1 -1
- package/dist/EmptyState-DgBRoYAy.js.map +0 -1
- package/dist/ExperimentPopover-BFw3QX8L.js.map +0 -1
- package/dist/ExperimentSelectorModal-BBWXC7KW.js.map +0 -1
- package/dist/auth-B87xM--s.js.map +0 -1
- package/dist/components-Ci_BTx00.js.map +0 -1
- package/dist/useProtocolTemplates-DeaOmMaw.js.map +0 -1
|
@@ -19,7 +19,6 @@ import type {
|
|
|
19
19
|
ControlSchema,
|
|
20
20
|
ControlWorkspaceOptions,
|
|
21
21
|
} from '../composables/useControlSchema'
|
|
22
|
-
import type { AppExperimentRecord } from '../composables/useAppExperiment'
|
|
23
22
|
|
|
24
23
|
export type PluginWorkspaceSidebarVariant = 'default' | 'analysis'
|
|
25
24
|
|
|
@@ -72,10 +71,8 @@ export interface PluginWorkspaceViewProps {
|
|
|
72
71
|
userInitial?: string
|
|
73
72
|
/** Classic profile email. */
|
|
74
73
|
userEmail?: string
|
|
75
|
-
/** Enable the built-in AppTopBar experiment popover and selector flow. */
|
|
74
|
+
/** Enable the built-in AppTopBar experiment popover and selector flow. The current experiment comes from the shared experiment store, not from a prop. Keep this comment on one line — the docs extractor drops multi-line JSDoc. */
|
|
76
75
|
experimentShell?: boolean
|
|
77
|
-
/** Current experiment mirrored into the AppTopBar experiment chip when experimentShell is enabled. */
|
|
78
|
-
experiment?: AppExperimentRecord | null
|
|
79
76
|
/** Save handler used by the AppTopBar experiment chip. Return a message to flash success. */
|
|
80
77
|
experimentSave?: () => string | null | Promise<string | null>
|
|
81
78
|
/** Disable the experiment save action. */
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { ref } from 'vue'
|
|
3
3
|
import type { ExperimentSummary, PillNavItem, SidebarToolSection } from '../types'
|
|
4
4
|
import { defineDoseDesignControlModel } from '../composables/useControlSchema'
|
|
5
|
-
import { ExperimentProvider
|
|
5
|
+
import { ExperimentProvider } from '../__stories__/experiment-helpers'
|
|
6
6
|
import PluginWorkspaceView from './PluginWorkspaceView.vue'
|
|
7
7
|
import AlertBox from './AlertBox.vue'
|
|
8
8
|
import BaseButton from './BaseButton.vue'
|
|
@@ -70,7 +70,9 @@ const doseDesignModel = defineDoseDesignControlModel({
|
|
|
70
70
|
},
|
|
71
71
|
})
|
|
72
72
|
const doseDesignValues = ref({})
|
|
73
|
-
|
|
73
|
+
// Read-only echo of the shell's selection events. The workspace itself reads
|
|
74
|
+
// the current experiment from the shared store, not from a prop.
|
|
75
|
+
const lastExperimentEvent = ref<string>('')
|
|
74
76
|
|
|
75
77
|
const state = ref({
|
|
76
78
|
title: 'Plugin Workspace',
|
|
@@ -83,11 +85,11 @@ const state = ref({
|
|
|
83
85
|
})
|
|
84
86
|
|
|
85
87
|
function handleExperimentSelect(experiment: ExperimentSummary) {
|
|
86
|
-
|
|
88
|
+
lastExperimentEvent.value = `selected: ${experiment.name}`
|
|
87
89
|
}
|
|
88
90
|
|
|
89
91
|
function handleExperimentDetach() {
|
|
90
|
-
|
|
92
|
+
lastExperimentEvent.value = 'detached'
|
|
91
93
|
}
|
|
92
94
|
|
|
93
95
|
async function saveDoseDesign() {
|
|
@@ -242,7 +244,6 @@ async function saveDoseDesign() {
|
|
|
242
244
|
sidebar-title="Design Controls"
|
|
243
245
|
sidebar-subtitle="Plate and dose setup"
|
|
244
246
|
experiment-shell
|
|
245
|
-
:experiment="shellExperiment"
|
|
246
247
|
:experiment-save="saveDoseDesign"
|
|
247
248
|
show-settings
|
|
248
249
|
show-theme-toggle
|
|
@@ -269,6 +270,12 @@ async function saveDoseDesign() {
|
|
|
269
270
|
</div>
|
|
270
271
|
</template>
|
|
271
272
|
</PluginWorkspaceView>
|
|
273
|
+
<p
|
|
274
|
+
v-if="lastExperimentEvent"
|
|
275
|
+
style="padding: 0.75rem 1rem; margin: 0; font-family: 'Fira Code', monospace; font-size: 0.75rem; color: var(--text-muted);"
|
|
276
|
+
>
|
|
277
|
+
Last shell event: {{ lastExperimentEvent }}
|
|
278
|
+
</p>
|
|
272
279
|
</div>
|
|
273
280
|
</ExperimentProvider>
|
|
274
281
|
</Variant>
|
|
@@ -52,7 +52,6 @@ const props = withDefaults(defineProps<PluginWorkspaceViewProps>(), {
|
|
|
52
52
|
userInitial: undefined,
|
|
53
53
|
userEmail: undefined,
|
|
54
54
|
experimentShell: false,
|
|
55
|
-
experiment: undefined,
|
|
56
55
|
experimentSave: undefined,
|
|
57
56
|
experimentSaveDisabled: false,
|
|
58
57
|
experimentSaveDisabledMessage: undefined,
|
|
@@ -144,7 +143,6 @@ const slots = useSlots()
|
|
|
144
143
|
|
|
145
144
|
if (props.experimentShell) {
|
|
146
145
|
useAppExperiment({
|
|
147
|
-
experiment: computed(() => props.experiment),
|
|
148
146
|
onSelect: experiment => emit('experiment-select', experiment),
|
|
149
147
|
onSave: props.experimentSave,
|
|
150
148
|
onDetach: () => emit('experiment-detach'),
|
|
@@ -67,6 +67,28 @@ export function resolveExperimentCode(experiment: ExperimentCodeSource): string
|
|
|
67
67
|
return undefined
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
+
// Mirrors vue-router's LocationQuery, whose array members are `string | null`.
|
|
71
|
+
// Declaring `string[]` here made the helper reject the very type every caller
|
|
72
|
+
// passes it, so keep this assignable from LocationQuery without importing
|
|
73
|
+
// vue-router (the SDK does not depend on it).
|
|
74
|
+
type RouteQueryValue = string | null | undefined | readonly (string | null | undefined)[]
|
|
75
|
+
type RouteQueryLike = Record<string, RouteQueryValue>
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Reads the experiment id a deep link was opened with.
|
|
79
|
+
*
|
|
80
|
+
* Returns an id and never a label: a display name belongs to the platform, so
|
|
81
|
+
* callers must resolve it (see `useExperimentStore().selectById`) rather than
|
|
82
|
+
* derive one from the query or the id itself.
|
|
83
|
+
*/
|
|
84
|
+
export function experimentIdFromRoute(query: RouteQueryLike): number | null {
|
|
85
|
+
const raw = query.experiment_id ?? query.experimentId
|
|
86
|
+
const value = Array.isArray(raw) ? raw.find(item => typeof item === 'string') : raw
|
|
87
|
+
if (typeof value !== 'string' || !value) return null
|
|
88
|
+
const id = Number(value)
|
|
89
|
+
return Number.isInteger(id) && id > 0 ? id : null
|
|
90
|
+
}
|
|
91
|
+
|
|
70
92
|
export const DATE_PRESET_OPTIONS: SelectOption<string>[] = [
|
|
71
93
|
{ value: '', label: 'Any time' },
|
|
72
94
|
{ value: 'last_7_days', label: 'Last 7 days' },
|
package/src/composables/index.ts
CHANGED
|
@@ -169,6 +169,7 @@ export {
|
|
|
169
169
|
formatExperimentStatus,
|
|
170
170
|
getExperimentStatusVariant,
|
|
171
171
|
resolveExperimentCode,
|
|
172
|
+
experimentIdFromRoute,
|
|
172
173
|
datePresetToISO,
|
|
173
174
|
EXPERIMENT_STATUS_OPTIONS,
|
|
174
175
|
EXPERIMENT_STATUS_VARIANT_MAP,
|
|
@@ -203,8 +204,6 @@ export {
|
|
|
203
204
|
export {
|
|
204
205
|
useAppExperiment,
|
|
205
206
|
APP_EXPERIMENT_KEY,
|
|
206
|
-
type AppExperimentRecord,
|
|
207
|
-
type AppExperimentSource,
|
|
208
207
|
type UseAppExperimentOptions,
|
|
209
208
|
type UseAppExperimentReturn,
|
|
210
209
|
type AppExperimentState,
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
ref,
|
|
3
3
|
computed,
|
|
4
|
-
readonly,
|
|
5
4
|
provide,
|
|
6
5
|
toValue,
|
|
7
6
|
watch,
|
|
@@ -11,21 +10,14 @@ import {
|
|
|
11
10
|
type InjectionKey,
|
|
12
11
|
} from 'vue'
|
|
13
12
|
import type { ExperimentSummary, ExperimentStatus } from '../types'
|
|
13
|
+
import { useExperimentStore } from '../stores/experiment'
|
|
14
14
|
import { resolveExperimentCode } from './experiment-utils'
|
|
15
15
|
|
|
16
|
-
export type AppExperimentRecord = Pick<ExperimentSummary, 'id' | 'name' | 'status' | 'experiment_code'>
|
|
17
|
-
|
|
18
|
-
export type AppExperimentSource =
|
|
19
|
-
| AppExperimentRecord
|
|
20
|
-
| null
|
|
21
|
-
| undefined
|
|
22
|
-
| Ref<AppExperimentRecord | null | undefined>
|
|
23
|
-
| ComputedRef<AppExperimentRecord | null | undefined>
|
|
24
|
-
| (() => AppExperimentRecord | null | undefined)
|
|
25
|
-
|
|
26
16
|
export interface UseAppExperimentOptions {
|
|
27
|
-
/**
|
|
28
|
-
|
|
17
|
+
/**
|
|
18
|
+
* Called when the shared store adopts an experiment — a modal selection or a
|
|
19
|
+
* resolved deep link. The record always comes from the platform.
|
|
20
|
+
*/
|
|
29
21
|
onSelect?: (experiment: ExperimentSummary) => void | Promise<void>
|
|
30
22
|
onSave?: () => string | null | Promise<string | null>
|
|
31
23
|
onDetach?: () => void
|
|
@@ -37,6 +29,10 @@ export interface AppExperimentPopoverBinding {
|
|
|
37
29
|
experimentName?: string
|
|
38
30
|
experimentCode?: string
|
|
39
31
|
experimentStatus?: ExperimentStatus
|
|
32
|
+
/** An experiment is bound but its record has not arrived yet. */
|
|
33
|
+
isResolving: boolean
|
|
34
|
+
/** Resolution failed; the binding is kept so the user can retry or detach. */
|
|
35
|
+
error?: string
|
|
40
36
|
showSave: boolean
|
|
41
37
|
showDetach: boolean
|
|
42
38
|
saveDisabled: boolean
|
|
@@ -51,26 +47,27 @@ export interface AppExperimentSelectorModalBinding {
|
|
|
51
47
|
}
|
|
52
48
|
|
|
53
49
|
export interface UseAppExperimentReturn {
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
50
|
+
experimentName: ComputedRef<string | undefined>
|
|
51
|
+
experimentCode: ComputedRef<string | undefined>
|
|
52
|
+
experimentStatus: ComputedRef<ExperimentStatus | undefined>
|
|
53
|
+
experimentId: ComputedRef<number | null>
|
|
54
|
+
isResolving: ComputedRef<boolean>
|
|
55
|
+
error: ComputedRef<string | null>
|
|
60
56
|
popover: ComputedRef<AppExperimentPopoverBinding>
|
|
61
57
|
selectorModal: ComputedRef<AppExperimentSelectorModalBinding>
|
|
62
58
|
openModal: () => void
|
|
63
59
|
closeModal: () => void
|
|
64
|
-
handleSelect: (experiment: ExperimentSummary) => void
|
|
65
60
|
handleSave: () => Promise<void>
|
|
66
61
|
handleDetach: () => void
|
|
67
62
|
}
|
|
68
63
|
|
|
69
64
|
export interface AppExperimentState {
|
|
70
|
-
experimentName:
|
|
71
|
-
experimentCode:
|
|
72
|
-
experimentStatus:
|
|
73
|
-
experimentId:
|
|
65
|
+
experimentName: ComputedRef<string | undefined>
|
|
66
|
+
experimentCode: ComputedRef<string | undefined>
|
|
67
|
+
experimentStatus: ComputedRef<ExperimentStatus | undefined>
|
|
68
|
+
experimentId: ComputedRef<number | null>
|
|
69
|
+
isResolving: ComputedRef<boolean>
|
|
70
|
+
error: ComputedRef<string | null>
|
|
74
71
|
showModal: Ref<boolean>
|
|
75
72
|
saveLoading: Ref<boolean>
|
|
76
73
|
saveSuccessMessage: Ref<string | undefined>
|
|
@@ -82,25 +79,41 @@ export interface AppExperimentState {
|
|
|
82
79
|
popover: ComputedRef<AppExperimentPopoverBinding>
|
|
83
80
|
selectorModal: ComputedRef<AppExperimentSelectorModalBinding>
|
|
84
81
|
openModal: () => void
|
|
85
|
-
handleSelect: (experiment: ExperimentSummary) => void
|
|
86
82
|
handleSave: () => Promise<void>
|
|
87
83
|
handleDetach: () => void
|
|
88
84
|
}
|
|
89
85
|
|
|
90
86
|
export const APP_EXPERIMENT_KEY: InjectionKey<AppExperimentState> = Symbol('app-experiment')
|
|
91
87
|
|
|
92
|
-
/**
|
|
88
|
+
/**
|
|
89
|
+
* Projects the current experiment from the shared store into an AppTopBar app
|
|
90
|
+
* shell, and owns the shell-local concerns the store has no opinion on: the
|
|
91
|
+
* selector modal's open state and the save flow.
|
|
92
|
+
*
|
|
93
|
+
* The composable never writes a selection of its own. `ExperimentSelectorModal`
|
|
94
|
+
* commits to the store and this reads back, so a displayed name can only ever
|
|
95
|
+
* have come from the platform.
|
|
96
|
+
*/
|
|
93
97
|
export function useAppExperiment(options: UseAppExperimentOptions = {}): UseAppExperimentReturn {
|
|
94
|
-
const
|
|
95
|
-
|
|
96
|
-
const experimentStatus = ref<ExperimentStatus | undefined>()
|
|
97
|
-
const experimentId = ref<number | null>(null)
|
|
98
|
+
const store = useExperimentStore()
|
|
99
|
+
|
|
98
100
|
const showModal = ref(false)
|
|
99
101
|
const saveLoading = ref(false)
|
|
100
102
|
const saveSuccessMessage = ref<string | undefined>()
|
|
101
103
|
|
|
102
104
|
let successTimer: ReturnType<typeof setTimeout> | null = null
|
|
103
105
|
|
|
106
|
+
const experimentName = computed(() => store.current?.name)
|
|
107
|
+
const experimentCode = computed(() =>
|
|
108
|
+
store.current ? resolveExperimentCode(store.current) : undefined,
|
|
109
|
+
)
|
|
110
|
+
const experimentStatus = computed(() => store.current?.status)
|
|
111
|
+
// Set while resolving and after a failed resolve, so the shell can tell
|
|
112
|
+
// "nothing selected" apart from "selected but not resolved".
|
|
113
|
+
const experimentId = computed(() => store.currentId)
|
|
114
|
+
const isResolving = computed(() => store.isResolving)
|
|
115
|
+
const error = computed(() => store.error)
|
|
116
|
+
|
|
104
117
|
const showSave = ref(!!options.onSave)
|
|
105
118
|
const showDetach = computed(() => experimentId.value !== null)
|
|
106
119
|
const saveDisabled = computed(() => toValue(options.saveDisabled) ?? false)
|
|
@@ -109,6 +122,8 @@ export function useAppExperiment(options: UseAppExperimentOptions = {}): UseAppE
|
|
|
109
122
|
experimentName: experimentName.value,
|
|
110
123
|
experimentCode: experimentCode.value,
|
|
111
124
|
experimentStatus: experimentStatus.value,
|
|
125
|
+
isResolving: isResolving.value,
|
|
126
|
+
error: error.value ?? undefined,
|
|
112
127
|
showSave: showSave.value,
|
|
113
128
|
showDetach: showDetach.value,
|
|
114
129
|
saveDisabled: saveDisabled.value,
|
|
@@ -121,31 +136,12 @@ export function useAppExperiment(options: UseAppExperimentOptions = {}): UseAppE
|
|
|
121
136
|
currentExperimentId: experimentId.value,
|
|
122
137
|
}))
|
|
123
138
|
|
|
124
|
-
|
|
125
|
-
experimentId.value = experiment.id
|
|
126
|
-
experimentName.value = experiment.name
|
|
127
|
-
experimentCode.value = resolveExperimentCode(experiment)
|
|
128
|
-
experimentStatus.value = experiment.status
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
function clear() {
|
|
132
|
-
experimentId.value = null
|
|
133
|
-
experimentName.value = undefined
|
|
134
|
-
experimentCode.value = undefined
|
|
135
|
-
experimentStatus.value = undefined
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
if (options.experiment !== undefined) {
|
|
139
|
+
if (options.onSelect) {
|
|
139
140
|
watch(
|
|
140
|
-
() =>
|
|
141
|
+
() => store.current,
|
|
141
142
|
(experiment) => {
|
|
142
|
-
if (experiment)
|
|
143
|
-
set(experiment)
|
|
144
|
-
} else {
|
|
145
|
-
clear()
|
|
146
|
-
}
|
|
143
|
+
if (experiment) void options.onSelect?.(experiment)
|
|
147
144
|
},
|
|
148
|
-
{ immediate: true },
|
|
149
145
|
)
|
|
150
146
|
}
|
|
151
147
|
|
|
@@ -157,12 +153,6 @@ export function useAppExperiment(options: UseAppExperimentOptions = {}): UseAppE
|
|
|
157
153
|
showModal.value = false
|
|
158
154
|
}
|
|
159
155
|
|
|
160
|
-
function handleSelect(experiment: ExperimentSummary) {
|
|
161
|
-
set(experiment)
|
|
162
|
-
showModal.value = false
|
|
163
|
-
options.onSelect?.(experiment)
|
|
164
|
-
}
|
|
165
|
-
|
|
166
156
|
async function handleSave() {
|
|
167
157
|
if (!options.onSave || saveLoading.value) return
|
|
168
158
|
saveLoading.value = true
|
|
@@ -182,7 +172,7 @@ export function useAppExperiment(options: UseAppExperimentOptions = {}): UseAppE
|
|
|
182
172
|
}
|
|
183
173
|
|
|
184
174
|
function handleDetach() {
|
|
185
|
-
clear()
|
|
175
|
+
store.clear()
|
|
186
176
|
options.onDetach?.()
|
|
187
177
|
}
|
|
188
178
|
|
|
@@ -195,6 +185,8 @@ export function useAppExperiment(options: UseAppExperimentOptions = {}): UseAppE
|
|
|
195
185
|
experimentCode,
|
|
196
186
|
experimentStatus,
|
|
197
187
|
experimentId,
|
|
188
|
+
isResolving,
|
|
189
|
+
error,
|
|
198
190
|
showModal,
|
|
199
191
|
saveLoading,
|
|
200
192
|
saveSuccessMessage,
|
|
@@ -206,7 +198,6 @@ export function useAppExperiment(options: UseAppExperimentOptions = {}): UseAppE
|
|
|
206
198
|
selectorModal,
|
|
207
199
|
openModal,
|
|
208
200
|
closeModal,
|
|
209
|
-
handleSelect,
|
|
210
201
|
handleSave,
|
|
211
202
|
handleDetach,
|
|
212
203
|
}
|
|
@@ -214,17 +205,16 @@ export function useAppExperiment(options: UseAppExperimentOptions = {}): UseAppE
|
|
|
214
205
|
provide(APP_EXPERIMENT_KEY, state)
|
|
215
206
|
|
|
216
207
|
return {
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
208
|
+
experimentName,
|
|
209
|
+
experimentCode,
|
|
210
|
+
experimentStatus,
|
|
211
|
+
experimentId,
|
|
212
|
+
isResolving,
|
|
213
|
+
error,
|
|
223
214
|
popover,
|
|
224
215
|
selectorModal,
|
|
225
216
|
openModal,
|
|
226
217
|
closeModal,
|
|
227
|
-
handleSelect,
|
|
228
218
|
handleSave,
|
|
229
219
|
handleDetach,
|
|
230
220
|
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { defineStore } from 'pinia'
|
|
2
|
+
import { ref } from 'vue'
|
|
3
|
+
import { useApi } from '../composables/useApi'
|
|
4
|
+
import type { ExperimentSummary, PlatformContext } from '../types'
|
|
5
|
+
|
|
6
|
+
function getPlatformApiUrl(): string | undefined {
|
|
7
|
+
if (typeof window === 'undefined') return undefined
|
|
8
|
+
return (window as unknown as { __MINT_PLATFORM__?: PlatformContext }).__MINT_PLATFORM__
|
|
9
|
+
?.platformApiUrl
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function readErrorMessage(value: unknown): string {
|
|
13
|
+
if (value instanceof Error && value.message) return value.message
|
|
14
|
+
if (typeof value === 'string' && value.trim()) return value
|
|
15
|
+
return 'Failed to load experiment'
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The single source of truth for the experiment a plugin is currently working on.
|
|
20
|
+
*
|
|
21
|
+
* Both entry points land here: `ExperimentSelectorModal` calls `select()` with a
|
|
22
|
+
* record the platform already returned, and deep links call `selectById()` with
|
|
23
|
+
* nothing but an id. Because a name can only ever arrive from the platform,
|
|
24
|
+
* there is no code path in which a caller can invent one.
|
|
25
|
+
*/
|
|
26
|
+
export const useExperimentStore = defineStore('mint-experiment', () => {
|
|
27
|
+
const current = ref<ExperimentSummary | null>(null)
|
|
28
|
+
const currentId = ref<number | null>(null)
|
|
29
|
+
const isResolving = ref(false)
|
|
30
|
+
const error = ref<string | null>(null)
|
|
31
|
+
|
|
32
|
+
// In-flight requests only. Resolved records are deliberately not memoised, so
|
|
33
|
+
// a rename is picked up on the next resolve instead of persisting for the
|
|
34
|
+
// lifetime of the page.
|
|
35
|
+
const inFlight = new Map<number, Promise<ExperimentSummary>>()
|
|
36
|
+
|
|
37
|
+
// Monotonic token guarding every write to the shared selection. Responses can
|
|
38
|
+
// land in any order, and a late one must never overwrite a newer choice —
|
|
39
|
+
// that would display a name belonging to a different experiment, which is the
|
|
40
|
+
// failure this store exists to prevent.
|
|
41
|
+
let selectionToken = 0
|
|
42
|
+
|
|
43
|
+
function fetchExperiment(id: number): Promise<ExperimentSummary> {
|
|
44
|
+
const pending = inFlight.get(id)
|
|
45
|
+
if (pending) return pending
|
|
46
|
+
|
|
47
|
+
const base = getPlatformApiUrl() ?? ''
|
|
48
|
+
const request = useApi()
|
|
49
|
+
.get<ExperimentSummary>(`${base}/experiments/${id}`)
|
|
50
|
+
.finally(() => {
|
|
51
|
+
inFlight.delete(id)
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
inFlight.set(id, request)
|
|
55
|
+
return request
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Adopt a record the platform already returned, without a further request. */
|
|
59
|
+
function select(experiment: ExperimentSummary): void {
|
|
60
|
+
selectionToken += 1
|
|
61
|
+
current.value = experiment
|
|
62
|
+
currentId.value = experiment.id
|
|
63
|
+
error.value = null
|
|
64
|
+
isResolving.value = false
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Bind to an experiment known only by id, resolving its record from the
|
|
69
|
+
* platform. On failure the binding is remembered but `current` stays null, so
|
|
70
|
+
* callers can distinguish "not resolved yet" from "nothing selected".
|
|
71
|
+
*/
|
|
72
|
+
async function selectById(id: number): Promise<ExperimentSummary | null> {
|
|
73
|
+
const joinsCurrentResolution = isResolving.value && currentId.value === id
|
|
74
|
+
const token = joinsCurrentResolution ? selectionToken : (selectionToken += 1)
|
|
75
|
+
if (!joinsCurrentResolution) {
|
|
76
|
+
current.value = null
|
|
77
|
+
currentId.value = id
|
|
78
|
+
error.value = null
|
|
79
|
+
isResolving.value = true
|
|
80
|
+
}
|
|
81
|
+
try {
|
|
82
|
+
const experiment = await fetchExperiment(id)
|
|
83
|
+
// A newer selection superseded this one while the request was in flight.
|
|
84
|
+
// Returning null tells the caller its result is no longer the selection,
|
|
85
|
+
// so it cannot be applied downstream either.
|
|
86
|
+
if (token !== selectionToken) return null
|
|
87
|
+
current.value = experiment
|
|
88
|
+
return experiment
|
|
89
|
+
} catch (err) {
|
|
90
|
+
if (token !== selectionToken) return null
|
|
91
|
+
current.value = null
|
|
92
|
+
error.value = readErrorMessage(err)
|
|
93
|
+
return null
|
|
94
|
+
} finally {
|
|
95
|
+
// Only the newest request owns the loading flag; otherwise a superseded
|
|
96
|
+
// response would clear it while the current one is still pending.
|
|
97
|
+
if (token === selectionToken) isResolving.value = false
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function clear(): void {
|
|
102
|
+
// Supersede any in-flight resolve so a detached experiment cannot be
|
|
103
|
+
// repopulated by a response that was already on its way.
|
|
104
|
+
selectionToken += 1
|
|
105
|
+
current.value = null
|
|
106
|
+
currentId.value = null
|
|
107
|
+
error.value = null
|
|
108
|
+
isResolving.value = false
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return {
|
|
112
|
+
current,
|
|
113
|
+
currentId,
|
|
114
|
+
isResolving,
|
|
115
|
+
error,
|
|
116
|
+
select,
|
|
117
|
+
selectById,
|
|
118
|
+
clear,
|
|
119
|
+
}
|
|
120
|
+
})
|
package/src/stores/index.ts
CHANGED
|
@@ -115,6 +115,47 @@
|
|
|
115
115
|
color: var(--color-primary);
|
|
116
116
|
}
|
|
117
117
|
|
|
118
|
+
/* ─── Unresolved states ───
|
|
119
|
+
An experiment is bound but has no name yet. The chip must not read "No
|
|
120
|
+
experiment", and it must never fall back to the numeric id — a name only
|
|
121
|
+
ever comes from the platform. */
|
|
122
|
+
.mint-experiment-popover__trigger--loading {
|
|
123
|
+
cursor: progress;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
.mint-experiment-popover__trigger--loading .mint-experiment-popover__trigger-icon {
|
|
127
|
+
color: var(--text-muted);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
.mint-experiment-popover__trigger--error {
|
|
131
|
+
border-color: var(--mint-error-border);
|
|
132
|
+
background: var(--mint-error-bg);
|
|
133
|
+
color: var(--mint-error);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
.mint-experiment-popover__trigger--error .mint-experiment-popover__trigger-icon,
|
|
137
|
+
.mint-experiment-popover__trigger--error .mint-experiment-popover__trigger-chevron {
|
|
138
|
+
color: var(--mint-error);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/* Placeholder bar standing in for the name while it resolves. */
|
|
142
|
+
.mint-experiment-popover__trigger-skeleton {
|
|
143
|
+
flex: 1;
|
|
144
|
+
min-width: 4.5rem;
|
|
145
|
+
height: 0.625rem;
|
|
146
|
+
border-radius: var(--radius-sm);
|
|
147
|
+
background-color: var(--bg-tertiary);
|
|
148
|
+
background-image: linear-gradient(
|
|
149
|
+
90deg,
|
|
150
|
+
var(--bg-tertiary) 0%,
|
|
151
|
+
var(--bg-hover) 40%,
|
|
152
|
+
var(--bg-tertiary) 60%
|
|
153
|
+
);
|
|
154
|
+
background-size: 200% 100%;
|
|
155
|
+
background-repeat: no-repeat;
|
|
156
|
+
animation: mint-experiment-popover-shimmer 1.6s ease-in-out infinite;
|
|
157
|
+
}
|
|
158
|
+
|
|
118
159
|
.mint-experiment-popover__trigger-chevron {
|
|
119
160
|
width: 0.8125rem;
|
|
120
161
|
height: 0.8125rem;
|
|
@@ -386,6 +427,59 @@
|
|
|
386
427
|
line-height: 1.3;
|
|
387
428
|
}
|
|
388
429
|
|
|
430
|
+
/* ─── Card: unresolved variants ─── */
|
|
431
|
+
.mint-experiment-popover__card--error {
|
|
432
|
+
background: var(--mint-error-bg);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
.mint-experiment-popover__card-icon--error {
|
|
436
|
+
background: transparent;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
.mint-experiment-popover__card-icon--error svg {
|
|
440
|
+
color: var(--mint-error);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
.mint-experiment-popover__card-message {
|
|
444
|
+
font-size: 0.6875rem;
|
|
445
|
+
color: var(--text-secondary);
|
|
446
|
+
line-height: 1.4;
|
|
447
|
+
overflow-wrap: anywhere;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/* Placeholder lines mirroring the code / name / status stack of a real card. */
|
|
451
|
+
.mint-experiment-popover__skeleton-line {
|
|
452
|
+
display: block;
|
|
453
|
+
height: 0.625rem;
|
|
454
|
+
border-radius: var(--radius-sm);
|
|
455
|
+
background-color: var(--bg-hover);
|
|
456
|
+
background-image: linear-gradient(
|
|
457
|
+
90deg,
|
|
458
|
+
var(--bg-hover) 0%,
|
|
459
|
+
var(--bg-secondary) 40%,
|
|
460
|
+
var(--bg-hover) 60%
|
|
461
|
+
);
|
|
462
|
+
background-size: 200% 100%;
|
|
463
|
+
background-repeat: no-repeat;
|
|
464
|
+
animation: mint-experiment-popover-shimmer 1.6s ease-in-out infinite;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
.mint-experiment-popover__skeleton-line--code {
|
|
468
|
+
width: 3.5rem;
|
|
469
|
+
height: 0.75rem;
|
|
470
|
+
border-radius: 9999px;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
.mint-experiment-popover__skeleton-line--name {
|
|
474
|
+
width: 100%;
|
|
475
|
+
margin-top: 0.25rem;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
.mint-experiment-popover__skeleton-line--status {
|
|
479
|
+
width: 45%;
|
|
480
|
+
margin-top: 0.25rem;
|
|
481
|
+
}
|
|
482
|
+
|
|
389
483
|
/* Action row sits BELOW the card (was inside) so the buttons have
|
|
390
484
|
room to breathe and don't compete with the info stack. */
|
|
391
485
|
.mint-experiment-popover__card-actions {
|
|
@@ -532,6 +626,11 @@
|
|
|
532
626
|
to { transform: rotate(360deg); }
|
|
533
627
|
}
|
|
534
628
|
|
|
629
|
+
@keyframes mint-experiment-popover-shimmer {
|
|
630
|
+
0% { background-position: 100% 0; }
|
|
631
|
+
100% { background-position: -100% 0; }
|
|
632
|
+
}
|
|
633
|
+
|
|
535
634
|
/* Respect motion preferences */
|
|
536
635
|
@media (prefers-reduced-motion: reduce) {
|
|
537
636
|
.mint-experiment-popover__trigger,
|
|
@@ -557,4 +656,10 @@
|
|
|
557
656
|
.mint-experiment-popover__trigger-chevron {
|
|
558
657
|
transition: none;
|
|
559
658
|
}
|
|
659
|
+
/* Placeholders stay as flat blocks — still legible as "not loaded yet". */
|
|
660
|
+
.mint-experiment-popover__trigger-skeleton,
|
|
661
|
+
.mint-experiment-popover__skeleton-line {
|
|
662
|
+
animation: none;
|
|
663
|
+
background-image: none;
|
|
664
|
+
}
|
|
560
665
|
}
|
package/src/types/auth.ts
CHANGED