@morscherlab/mint-sdk 1.1.0 → 1.1.2

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 (99) hide show
  1. package/dist/{EmptyState-DgBRoYAy.js → EmptyState-LMPS3keb.js} +3 -193
  2. package/dist/EmptyState-LMPS3keb.js.map +1 -0
  3. package/dist/{ExperimentPopover-BFw3QX8L.js → ExperimentPopover-D6_RGzTY.js} +113 -35
  4. package/dist/ExperimentPopover-D6_RGzTY.js.map +1 -0
  5. package/dist/{ExperimentSelectorModal-BBWXC7KW.js → ExperimentSelectorModal-DcQ38zua.js} +9 -11
  6. package/dist/ExperimentSelectorModal-DcQ38zua.js.map +1 -0
  7. package/dist/{SettingsModal-Dpg-X1OM.js → SettingsModal-Ccq2z-Ms.js} +9 -6
  8. package/dist/SettingsModal-Ccq2z-Ms.js.map +1 -0
  9. package/dist/__stories__/experiment-helpers.d.ts +3 -0
  10. package/dist/__tests__/stores/experiment.test.d.ts +1 -0
  11. package/dist/{analysisArtifactTypes-ejqR8MX3.js → analysisArtifactTypes-DQ0MjwHZ.js} +1 -1
  12. package/dist/{analysisArtifactTypes-ejqR8MX3.js.map → analysisArtifactTypes-DQ0MjwHZ.js.map} +1 -1
  13. package/dist/components/ExperimentPopover.vue.d.ts +5 -0
  14. package/dist/components/ExperimentSelectorModal.vue.d.ts +1 -5
  15. package/dist/components/PluginWorkspaceView.props.d.ts +1 -4
  16. package/dist/components/PluginWorkspaceView.vue.d.ts +0 -1
  17. package/dist/components/SmartGroupFieldRecipe.vue.d.ts +2 -2
  18. package/dist/components/index.js +7 -7
  19. package/dist/{components-Ci_BTx00.js → components-CV5zuWa8.js} +24 -29
  20. package/dist/components-CV5zuWa8.js.map +1 -0
  21. package/dist/composables/experiment-utils.d.ts +10 -0
  22. package/dist/composables/index.d.ts +2 -2
  23. package/dist/composables/index.js +11 -10
  24. package/dist/composables/useAppExperiment.d.ts +29 -17
  25. package/dist/composables/usePlatformContext.d.ts +3 -3
  26. package/dist/{composables-C0VEJ73O.js → composables-CW8NRXLB.js} +5 -5
  27. package/dist/{composables-C0VEJ73O.js.map → composables-CW8NRXLB.js.map} +1 -1
  28. package/dist/experiment-Y9Iz7KYi.js +95 -0
  29. package/dist/experiment-Y9Iz7KYi.js.map +1 -0
  30. package/dist/{experiment-utils-CxkruZ3G.js → experiment-utils-7Yw-h0tD.js} +16 -2
  31. package/dist/{experiment-utils-CxkruZ3G.js.map → experiment-utils-7Yw-h0tD.js.map} +1 -1
  32. package/dist/index.js +16 -15
  33. package/dist/index.js.map +1 -1
  34. package/dist/install.js +7 -7
  35. package/dist/{plotly.min-BR53hja4.js → plotly.min-c2Sez60c.js} +1 -1
  36. package/dist/{plotly.min-BR53hja4.js.map → plotly.min-c2Sez60c.js.map} +1 -1
  37. package/dist/stores/auth.d.ts +6 -6
  38. package/dist/stores/experiment.d.ts +166 -0
  39. package/dist/stores/index.d.ts +1 -0
  40. package/dist/stores/index.js +3 -2
  41. package/dist/styles.css +188 -0
  42. package/dist/templates/index.js +4 -4
  43. package/dist/{templates-Cdj93S9C.js → templates-CJRFiWUX.js} +2 -2
  44. package/dist/{templates-Cdj93S9C.js.map → templates-CJRFiWUX.js.map} +1 -1
  45. package/dist/types/auth.d.ts +1 -1
  46. package/dist/types/index.js +1 -1
  47. package/dist/types/platform.d.ts +1 -1
  48. package/dist/{auth-B87xM--s.js → useApi-CDBq6z23.js} +191 -2
  49. package/dist/useApi-CDBq6z23.js.map +1 -0
  50. package/dist/{useControlSchema-B_qgU4rW.js → useControlSchema-BEAgLyyx.js} +5 -4
  51. package/dist/{useControlSchema-B_qgU4rW.js.map → useControlSchema-BEAgLyyx.js.map} +1 -1
  52. package/dist/{useDropdownState-gVaMXtwW.js → useDropdownState-Be-ug_ci.js} +2 -2
  53. package/dist/{useDropdownState-gVaMXtwW.js.map → useDropdownState-Be-ug_ci.js.map} +1 -1
  54. package/dist/{useExperimentSelector-BuQZRgMt.js → useExperimentSelector-DFg_iEHI.js} +36 -16
  55. package/dist/useExperimentSelector-DFg_iEHI.js.map +1 -0
  56. package/dist/{useFormBuilder-D1G5J5Z5.js → useFormBuilder-CR9pBApW.js} +2 -2
  57. package/dist/{useFormBuilder-D1G5J5Z5.js.map → useFormBuilder-CR9pBApW.js.map} +1 -1
  58. package/dist/{useProtocolTemplates-DeaOmMaw.js → useProtocolTemplates-DXeIHMwV.js} +38 -42
  59. package/dist/useProtocolTemplates-DXeIHMwV.js.map +1 -0
  60. package/package.json +1 -1
  61. package/src/__stories__/experiment-helpers.ts +12 -9
  62. package/src/__tests__/components/AppTopBar.test.ts +10 -6
  63. package/src/__tests__/components/ExperimentPopover.test.ts +64 -0
  64. package/src/__tests__/components/ExperimentSelectorModal.test.ts +86 -41
  65. package/src/__tests__/components/PluginWorkspaceView.test.ts +103 -19
  66. package/src/__tests__/components/SettingsModal.test.ts +29 -1
  67. package/src/__tests__/composables/experiment-utils.test.ts +33 -0
  68. package/src/__tests__/composables/useAppExperiment.test.ts +331 -92
  69. package/src/__tests__/composables/useAuth.test.ts +2 -2
  70. package/src/__tests__/composables/useExperimentSelector.test.ts +71 -0
  71. package/src/__tests__/composables/usePlatformContext.test.ts +5 -1
  72. package/src/__tests__/stores/experiment.test.ts +300 -0
  73. package/src/components/AppTopBar.vue +0 -2
  74. package/src/components/ExperimentPopover.story.vue +59 -0
  75. package/src/components/ExperimentPopover.vue +96 -6
  76. package/src/components/ExperimentSelectorModal.story.vue +6 -16
  77. package/src/components/ExperimentSelectorModal.vue +8 -4
  78. package/src/components/PluginWorkspaceView.props.ts +1 -4
  79. package/src/components/PluginWorkspaceView.story.vue +12 -5
  80. package/src/components/PluginWorkspaceView.vue +0 -2
  81. package/src/components/SettingsModal.vue +6 -1
  82. package/src/composables/experiment-utils.ts +22 -0
  83. package/src/composables/index.ts +1 -2
  84. package/src/composables/useAppExperiment.ts +57 -67
  85. package/src/composables/useAuth.ts +1 -1
  86. package/src/composables/useExperimentSelector.ts +45 -13
  87. package/src/stores/experiment.ts +120 -0
  88. package/src/stores/index.ts +1 -0
  89. package/src/styles/components/experiment-popover.css +105 -0
  90. package/src/types/auth.ts +1 -1
  91. package/src/types/platform.ts +1 -1
  92. package/dist/EmptyState-DgBRoYAy.js.map +0 -1
  93. package/dist/ExperimentPopover-BFw3QX8L.js.map +0 -1
  94. package/dist/ExperimentSelectorModal-BBWXC7KW.js.map +0 -1
  95. package/dist/SettingsModal-Dpg-X1OM.js.map +0 -1
  96. package/dist/auth-B87xM--s.js.map +0 -1
  97. package/dist/components-Ci_BTx00.js.map +0 -1
  98. package/dist/useExperimentSelector-BuQZRgMt.js.map +0 -1
  99. package/dist/useProtocolTemplates-DeaOmMaw.js.map +0 -1
@@ -1,7 +1,8 @@
1
1
  <script setup lang="ts">
2
- import { ref, nextTick, onMounted } from 'vue'
2
+ import { ref, computed, nextTick, onMounted } from 'vue'
3
3
  import ExperimentSelectorModal from './ExperimentSelectorModal.vue'
4
4
  import ExperimentCodeBadge from './ExperimentCodeBadge.vue'
5
+ import { useExperimentStore } from '../stores/experiment'
5
6
  import type { ExperimentSummary, ExperimentStatus, ExperimentSortField } from '../types'
6
7
 
7
8
  // -- Mock data: realistic cell biology experiments --
@@ -247,15 +248,11 @@ const isOpenEmpty = ref(false)
247
248
  const isOpenGrouped = ref(false)
248
249
  const isOpenWithSelection = ref(false)
249
250
  const isOpenAdvanced = ref(false)
250
- const selectedExperiment = ref<ExperimentSummary | null>(null)
251
251
 
252
- function handleSelect(experiment: ExperimentSummary) {
253
- selectedExperiment.value = experiment
254
- }
255
-
256
- function handleDeselect() {
257
- selectedExperiment.value = null
258
- }
252
+ // The modal commits to the shared store rather than emitting; stories read the
253
+ // selection back from the same place a plugin would.
254
+ const experimentStore = useExperimentStore()
255
+ const selectedExperiment = computed(() => experimentStore.current)
259
256
  </script>
260
257
 
261
258
  <template>
@@ -290,8 +287,6 @@ function handleDeselect() {
290
287
  :experiment-type="state.experimentType || undefined"
291
288
  :title="state.title"
292
289
  :current-experiment-id="selectedExperiment?.id ?? null"
293
- @select="handleSelect"
294
- @deselect="handleDeselect"
295
290
  />
296
291
  </div>
297
292
  </template>
@@ -319,7 +314,6 @@ function handleDeselect() {
319
314
  v-model="isOpenAdvanced"
320
315
  title="Select Experiment"
321
316
  :show-filters="true"
322
- @select="handleSelect"
323
317
  />
324
318
  </div>
325
319
  </Variant>
@@ -340,7 +334,6 @@ function handleDeselect() {
340
334
  v-model="isOpenGrouped"
341
335
  title="Select Experiment"
342
336
  :group-by-project="true"
343
- @select="handleSelect"
344
337
  />
345
338
  </div>
346
339
  </Variant>
@@ -361,8 +354,6 @@ function handleDeselect() {
361
354
  v-model="isOpenWithSelection"
362
355
  title="Select Experiment"
363
356
  :current-experiment-id="1"
364
- @select="handleSelect"
365
- @deselect="handleDeselect"
366
357
  />
367
358
  </div>
368
359
  </Variant>
@@ -383,7 +374,6 @@ function handleDeselect() {
383
374
  v-model="isOpenEmpty"
384
375
  experiment-type="nonexistent_type"
385
376
  title="Select Experiment"
386
- @select="handleSelect"
387
377
  />
388
378
  </div>
389
379
  </Variant>
@@ -3,6 +3,7 @@
3
3
  import { ref, reactive, computed, watch, nextTick } from 'vue'
4
4
  import type { ModalSize, ExperimentSummary, ExperimentFilters } from '../types'
5
5
  import { useExperimentSelector } from '../composables/useExperimentSelector'
6
+ import { useExperimentStore } from '../stores/experiment'
6
7
  import {
7
8
  formatExperimentDate,
8
9
  formatExperimentStatus,
@@ -38,12 +39,15 @@ const props = withDefaults(defineProps<Props>(), {
38
39
  showFilters: false,
39
40
  })
40
41
 
42
+ // Selection is committed to the shared experiment store rather than emitted.
43
+ // A single owner of "the current experiment" is what stops callers holding —
44
+ // or inventing — a record of their own.
41
45
  const emit = defineEmits<{
42
46
  'update:modelValue': [value: boolean]
43
- select: [experiment: ExperimentSummary]
44
- deselect: []
45
47
  }>()
46
48
 
49
+ const experimentStore = useExperimentStore()
50
+
47
51
  const {
48
52
  experiments,
49
53
  filters,
@@ -113,12 +117,12 @@ function handleSortChange(value: string | number) {
113
117
  }
114
118
 
115
119
  function handleSelect(experiment: ExperimentSummary) {
116
- emit('select', experiment)
120
+ experimentStore.select(experiment)
117
121
  emit('update:modelValue', false)
118
122
  }
119
123
 
120
124
  function handleDeselect() {
121
- emit('deselect')
125
+ experimentStore.clear()
122
126
  emit('update:modelValue', false)
123
127
  }
124
128
 
@@ -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, mockExperiments } from '../__stories__/experiment-helpers'
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
- const shellExperiment = ref<ExperimentSummary | null>(mockExperiments[0] ?? null)
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
- shellExperiment.value = experiment
88
+ lastExperimentEvent.value = `selected: ${experiment.name}`
87
89
  }
88
90
 
89
91
  function handleExperimentDetach() {
90
- shellExperiment.value = null
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'),
@@ -191,7 +191,12 @@ watch(
191
191
  watch(
192
192
  () => ({ ...builder.form.data }),
193
193
  (data) => {
194
- if (settingsSchema.value) emit('update:values', data as Record<string, unknown>)
194
+ if (settingsSchema.value) {
195
+ emit('update:values', {
196
+ ...resolvedValues.value,
197
+ ...data,
198
+ })
199
+ }
195
200
  },
196
201
  { deep: true },
197
202
  )
@@ -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' },
@@ -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
- /** Current experiment source to mirror into the app shell. Accepts a value, ref, computed, or getter. */
28
- experiment?: AppExperimentSource
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
- set: (experiment: AppExperimentRecord) => void
55
- clear: () => void
56
- experimentName: Readonly<Ref<string | undefined>>
57
- experimentCode: Readonly<Ref<string | undefined>>
58
- experimentStatus: Readonly<Ref<ExperimentStatus | undefined>>
59
- experimentId: Readonly<Ref<number | null>>
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: Ref<string | undefined>
71
- experimentCode: Ref<string | undefined>
72
- experimentStatus: Ref<ExperimentStatus | undefined>
73
- experimentId: Ref<number | null>
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
- /** Manages the current experiment selection, save flow, and detach action for a plugin's AppTopBar app shell. */
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 experimentName = ref<string | undefined>()
95
- const experimentCode = ref<string | undefined>()
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
- function set(experiment: AppExperimentRecord) {
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
- () => toValue(options.experiment),
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
- set,
218
- clear,
219
- experimentName: readonly(experimentName),
220
- experimentCode: readonly(experimentCode),
221
- experimentStatus: readonly(experimentStatus),
222
- experimentId: readonly(experimentId),
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
  }
@@ -6,7 +6,7 @@ import type { AuthConfig, UserInfo, LoginResponse, TokenVerifyResponse, UpdatePr
6
6
  import type { RoleInfo } from '../permissions'
7
7
 
8
8
  interface UserResponse {
9
- id: string
9
+ id: number
10
10
  username: string
11
11
  shortname: string | null
12
12
  email: string | null
@@ -97,6 +97,7 @@ export function useExperimentSelector(
97
97
  const error = request.error
98
98
  const lastLoadedAt = request.lastLoadedAt
99
99
  const page = ref(0)
100
+ let fetchGeneration = 0
100
101
 
101
102
  // Sort: combined key like "created_at:desc"
102
103
  const sortKey = ref<string>('created_at:desc')
@@ -201,9 +202,11 @@ export function useExperimentSelector(
201
202
  return await api.get<ExperimentListResponse>(buildExperimentListUrl(typeFilter, skip, pageLimit))
202
203
  }
203
204
 
204
- async function fetchScopedExperimentPage(): Promise<ExperimentListResponse> {
205
+ async function fetchScopedExperimentPage(
206
+ requestedPage: number,
207
+ ): Promise<ExperimentListResponse> {
205
208
  const scope = resolveExperimentTypeScope()
206
- const pageSkip = page.value * limit
209
+ const pageSkip = requestedPage * limit
207
210
 
208
211
  if (scope.kind === 'blocked') {
209
212
  return { experiments: [], total: 0 }
@@ -229,24 +232,39 @@ export function useExperimentSelector(
229
232
  }
230
233
 
231
234
  async function fetchExperiments(): Promise<void> {
235
+ const generation = ++fetchGeneration
236
+ const requestedPage = page.value
237
+ isLoading.value = true
238
+ request.clearError()
232
239
  try {
233
- await request.run(async () => {
234
- const data = await fetchScopedExperimentPage()
235
- if (page.value === 0) {
236
- experiments.value = data.experiments
237
- } else {
238
- experiments.value = [...experiments.value, ...data.experiments]
239
- }
240
- total.value = data.total
241
- }, { success: 'load', errorMessage: 'Failed to fetch experiments' })
242
- } catch {
243
- if (page.value === 0) {
240
+ const data = await fetchScopedExperimentPage(requestedPage)
241
+ if (generation !== fetchGeneration) return
242
+ if (requestedPage === 0) {
243
+ experiments.value = data.experiments
244
+ } else {
245
+ experiments.value = [...experiments.value, ...data.experiments]
246
+ }
247
+ total.value = data.total
248
+ request.markLoaded()
249
+ } catch (err) {
250
+ if (generation !== fetchGeneration) return
251
+ request.setError(err, 'Failed to fetch experiments')
252
+ if (requestedPage === 0) {
244
253
  experiments.value = []
245
254
  total.value = 0
246
255
  }
256
+ } finally {
257
+ if (generation === fetchGeneration) {
258
+ isLoading.value = false
259
+ }
247
260
  }
248
261
  }
249
262
 
263
+ function invalidateFetch(): void {
264
+ fetchGeneration += 1
265
+ isLoading.value = false
266
+ }
267
+
250
268
  async function fetchFilterOptions(): Promise<void> {
251
269
  if (filterOptionsFetched) return
252
270
  filterOptionsFetched = true
@@ -298,6 +316,7 @@ export function useExperimentSelector(
298
316
  }
299
317
 
300
318
  function clear(): void {
319
+ invalidateFetch()
301
320
  selectedExperiment.value = null
302
321
  filters.search = undefined
303
322
  filters.status = undefined
@@ -329,6 +348,19 @@ export function useExperimentSelector(
329
348
  })
330
349
  })
331
350
 
351
+ watch(
352
+ () => [
353
+ filters.search,
354
+ filters.status,
355
+ filters.project,
356
+ filters.experimentType,
357
+ filters.datePreset,
358
+ sortKey.value,
359
+ ],
360
+ () => invalidateFetch(),
361
+ { flush: 'sync' },
362
+ )
363
+
332
364
  const searchWatch = useDebouncedWatch(
333
365
  () => filters.search,
334
366
  () => {