@morscherlab/mld-sdk 0.9.3 → 0.9.5

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.
@@ -95,9 +95,12 @@ export {
95
95
  } from './useExperimentSelector'
96
96
  export {
97
97
  formatExperimentDate,
98
+ datePresetToISO,
98
99
  EXPERIMENT_STATUS_OPTIONS,
99
100
  EXPERIMENT_STATUS_VARIANT_MAP,
100
101
  EXPERIMENT_STATUS_LABELS,
102
+ DATE_PRESET_OPTIONS,
103
+ SORT_OPTIONS,
101
104
  } from './experiment-utils'
102
105
  export {
103
106
  useExperimentData,
@@ -1,6 +1,15 @@
1
1
  import { ref, reactive, computed, watch, onScopeDispose, type Ref, type ComputedRef } from 'vue'
2
2
  import { useApi } from './useApi'
3
- import type { ExperimentSummary, ExperimentListResponse, ExperimentFilters, PlatformContext } from '../types'
3
+ import { datePresetToISO } from './experiment-utils'
4
+ import type {
5
+ ExperimentSummary,
6
+ ExperimentListResponse,
7
+ ExperimentFilters,
8
+ ExperimentTypeOption,
9
+ ExperimentSortField,
10
+ PlatformContext,
11
+ SelectOption,
12
+ } from '../types'
4
13
 
5
14
  function getPlatformContext(): PlatformContext | undefined {
6
15
  if (typeof window === 'undefined') return undefined
@@ -27,11 +36,20 @@ export interface UseExperimentSelectorReturn {
27
36
  error: Ref<string | null>
28
37
  page: Ref<number>
29
38
  hasMore: ComputedRef<boolean>
39
+ // Sort
40
+ sortKey: Ref<string>
41
+ // Filter options
42
+ experimentTypes: Ref<ExperimentTypeOption[]>
43
+ projects: Ref<SelectOption<string>[]>
44
+ // Grouped view
45
+ groupedByProject: ComputedRef<[string, ExperimentSummary[]][]>
46
+ // Methods
30
47
  fetch: () => Promise<void>
31
48
  loadMore: () => Promise<void>
32
49
  reset: () => void
33
50
  select: (experiment: ExperimentSummary) => void
34
51
  clear: () => void
52
+ fetchFilterOptions: () => Promise<void>
35
53
  }
36
54
 
37
55
  export function useExperimentSelector(
@@ -48,26 +66,62 @@ export function useExperimentSelector(
48
66
  const error = ref<string | null>(null)
49
67
  const page = ref(0)
50
68
 
69
+ // Sort: combined key like "created_at:desc"
70
+ const sortKey = ref<string>('created_at:desc')
71
+
72
+ // Filter option data (fetched once, cached)
73
+ const experimentTypes = ref<ExperimentTypeOption[]>([])
74
+ const projects = ref<SelectOption<string>[]>([])
75
+ let filterOptionsFetched = false
76
+
51
77
  const hasMore = computed(() => experiments.value.length < total.value)
52
78
 
79
+ const filters: ExperimentFilters = reactive({
80
+ search: undefined,
81
+ status: undefined,
82
+ project: undefined,
83
+ experimentType: undefined,
84
+ datePreset: undefined,
85
+ })
86
+
87
+ function parseSortKey(): { sortBy: ExperimentSortField; sortOrder: 'asc' | 'desc' } {
88
+ const [field, order] = sortKey.value.split(':')
89
+ return {
90
+ sortBy: (field || 'created_at') as ExperimentSortField,
91
+ sortOrder: (order || 'desc') as 'asc' | 'desc',
92
+ }
93
+ }
94
+
53
95
  async function fetchExperiments(): Promise<void> {
54
96
  isLoading.value = true
55
97
  error.value = null
56
98
  try {
57
99
  const params = new URLSearchParams()
58
- // Priority: explicit option > platform context (single type) > no filter
100
+ // Priority: explicit option > platform context (single type) > filter dropdown > no filter
59
101
  const allowedTypes = getPlatformContext()?.allowedExperimentTypes
60
102
  const effectiveType = experimentType
61
103
  ?? (allowedTypes?.length === 1 ? allowedTypes[0] : undefined)
104
+ ?? filters.experimentType
105
+ ?? undefined
62
106
  if (effectiveType) params.set('experiment_type', effectiveType)
63
107
  if (filters.status) params.set('status', filters.status)
64
108
  if (filters.search) params.set('search', filters.search)
65
109
  if (filters.project) params.set('project', filters.project)
110
+
111
+ // Sort params
112
+ const { sortBy, sortOrder } = parseSortKey()
113
+ params.set('sort_by', sortBy)
114
+ params.set('sort_order', sortOrder)
115
+
116
+ // Date preset → created_after
117
+ if (filters.datePreset) {
118
+ params.set('created_after', datePresetToISO(filters.datePreset))
119
+ }
120
+
66
121
  params.set('limit', String(limit))
67
122
  params.set('skip', String(page.value * limit))
68
123
 
69
124
  const query = params.toString()
70
- // Use absolute platform URL in integrated mode to bypass plugin's baseURL
71
125
  const base = platformBase ?? '/api'
72
126
  const url = `${base}/experiments${query ? `?${query}` : ''}`
73
127
  const data = await api.get<ExperimentListResponse>(url)
@@ -96,6 +150,32 @@ export function useExperimentSelector(
96
150
  }
97
151
  }
98
152
 
153
+ async function fetchFilterOptions(): Promise<void> {
154
+ if (filterOptionsFetched) return
155
+ filterOptionsFetched = true
156
+
157
+ const base = platformBase ?? '/api'
158
+ const [typesRes, projectsRes] = await Promise.allSettled([
159
+ api.get<Array<{ name: string; color?: string }>>(`${base}/experiments/experiment-types`),
160
+ api.get<Array<{ id: number; name: string }>>(`${base}/projects`),
161
+ ])
162
+
163
+ if (typesRes.status === 'fulfilled' && Array.isArray(typesRes.value)) {
164
+ experimentTypes.value = typesRes.value.map(t => ({
165
+ value: t.name,
166
+ label: t.name,
167
+ color: t.color,
168
+ }))
169
+ }
170
+
171
+ if (projectsRes.status === 'fulfilled' && Array.isArray(projectsRes.value)) {
172
+ projects.value = projectsRes.value.map(p => ({
173
+ value: p.name,
174
+ label: p.name,
175
+ }))
176
+ }
177
+ }
178
+
99
179
  async function loadMore(): Promise<void> {
100
180
  if (!hasMore.value || isLoading.value) return
101
181
  page.value++
@@ -118,13 +198,30 @@ export function useExperimentSelector(
118
198
  filters.search = undefined
119
199
  filters.status = undefined
120
200
  filters.project = undefined
201
+ filters.experimentType = undefined
202
+ filters.datePreset = undefined
203
+ sortKey.value = 'created_at:desc'
121
204
  page.value = 0
122
205
  }
123
206
 
124
- const filters: ExperimentFilters = reactive({
125
- search: undefined,
126
- status: undefined,
127
- project: undefined,
207
+ // Group experiments by project (client-side)
208
+ const groupedByProject = computed<[string, ExperimentSummary[]][]>(() => {
209
+ const groups = new Map<string, ExperimentSummary[]>()
210
+ for (const exp of experiments.value) {
211
+ const key = exp.project_name ?? exp.project ?? 'No project'
212
+ const list = groups.get(key)
213
+ if (list) {
214
+ list.push(exp)
215
+ } else {
216
+ groups.set(key, [exp])
217
+ }
218
+ }
219
+ // Sort alphabetically, "No project" last
220
+ return [...groups.entries()].sort(([a], [b]) => {
221
+ if (a === 'No project') return 1
222
+ if (b === 'No project') return -1
223
+ return a.localeCompare(b)
224
+ })
128
225
  })
129
226
 
130
227
  // Debounced watch on search filter
@@ -140,10 +237,12 @@ export function useExperimentSelector(
140
237
  },
141
238
  )
142
239
 
143
- // Immediate watch on status/project filters (no debounce needed)
240
+ // Immediate watch on discrete filters and sort (cancel any pending search debounce)
144
241
  watch(
145
- () => [filters.status, filters.project],
242
+ () => [filters.status, filters.project, filters.experimentType, filters.datePreset, sortKey.value],
146
243
  () => {
244
+ if (debounceTimer) clearTimeout(debounceTimer)
245
+ debounceTimer = null
147
246
  page.value = 0
148
247
  fetchExperiments()
149
248
  },
@@ -166,10 +265,15 @@ export function useExperimentSelector(
166
265
  error,
167
266
  page,
168
267
  hasMore,
268
+ sortKey,
269
+ experimentTypes,
270
+ projects,
271
+ groupedByProject,
169
272
  fetch: fetchExperiments,
170
273
  loadMore,
171
274
  reset,
172
275
  select,
173
276
  clear,
277
+ fetchFilterOptions,
174
278
  }
175
279
  }
package/src/index.ts CHANGED
@@ -153,9 +153,12 @@ export {
153
153
  type UseExperimentSelectorReturn,
154
154
  // Experiment utilities
155
155
  formatExperimentDate,
156
+ datePresetToISO,
156
157
  EXPERIMENT_STATUS_OPTIONS,
157
158
  EXPERIMENT_STATUS_VARIANT_MAP,
158
159
  EXPERIMENT_STATUS_LABELS,
160
+ DATE_PRESET_OPTIONS,
161
+ SORT_OPTIONS,
159
162
  // Experiment data
160
163
  useExperimentData,
161
164
  type UseExperimentDataOptions,
@@ -5,6 +5,8 @@ import type { AuthConfig, UserInfo } from '../types'
5
5
  const AUTH_TOKEN_KEY = 'mld-auth-token'
6
6
  const AUTH_EXPIRES_KEY = 'mld-auth-expires'
7
7
 
8
+ const hasLocalStorage = typeof localStorage !== 'undefined' && typeof localStorage.getItem === 'function'
9
+
8
10
  export const useAuthStore = defineStore('mld-auth', () => {
9
11
  // State
10
12
  const token = ref<string | null>(null)
@@ -50,18 +52,20 @@ export const useAuthStore = defineStore('mld-auth', () => {
50
52
 
51
53
  // Actions
52
54
  function initialize() {
53
- const storedToken = localStorage.getItem(AUTH_TOKEN_KEY)
54
- const storedExpires = localStorage.getItem(AUTH_EXPIRES_KEY)
55
-
56
- if (storedToken) {
57
- token.value = storedToken
58
-
59
- if (storedExpires) {
60
- const expires = new Date(storedExpires)
61
- if (expires > new Date()) {
62
- tokenExpires.value = expires
63
- } else {
64
- clearToken()
55
+ if (hasLocalStorage) {
56
+ const storedToken = localStorage.getItem(AUTH_TOKEN_KEY)
57
+ const storedExpires = localStorage.getItem(AUTH_EXPIRES_KEY)
58
+
59
+ if (storedToken) {
60
+ token.value = storedToken
61
+
62
+ if (storedExpires) {
63
+ const expires = new Date(storedExpires)
64
+ if (expires > new Date()) {
65
+ tokenExpires.value = expires
66
+ } else {
67
+ clearToken()
68
+ }
65
69
  }
66
70
  }
67
71
  }
@@ -74,8 +78,10 @@ export const useAuthStore = defineStore('mld-auth', () => {
74
78
  const expires = new Date(Date.now() + expiresIn * 1000)
75
79
  tokenExpires.value = expires
76
80
 
77
- localStorage.setItem(AUTH_TOKEN_KEY, accessToken)
78
- localStorage.setItem(AUTH_EXPIRES_KEY, expires.toISOString())
81
+ if (hasLocalStorage) {
82
+ localStorage.setItem(AUTH_TOKEN_KEY, accessToken)
83
+ localStorage.setItem(AUTH_EXPIRES_KEY, expires.toISOString())
84
+ }
79
85
  }
80
86
 
81
87
  function clearToken() {
@@ -84,8 +90,10 @@ export const useAuthStore = defineStore('mld-auth', () => {
84
90
  username.value = null
85
91
  userInfo.value = null
86
92
 
87
- localStorage.removeItem(AUTH_TOKEN_KEY)
88
- localStorage.removeItem(AUTH_EXPIRES_KEY)
93
+ if (hasLocalStorage) {
94
+ localStorage.removeItem(AUTH_TOKEN_KEY)
95
+ localStorage.removeItem(AUTH_EXPIRES_KEY)
96
+ }
89
97
  }
90
98
 
91
99
  function setUserInfo(info: UserInfo) {
@@ -6,7 +6,16 @@
6
6
  gap: 0.75rem;
7
7
  }
8
8
 
9
- /* Filter bar */
9
+ /* Shared filter row layout */
10
+ .mld-experiment-selector__filters-row,
11
+ .mld-experiment-selector__filters-advanced {
12
+ display: flex;
13
+ align-items: center;
14
+ gap: 0.5rem;
15
+ flex-wrap: wrap;
16
+ }
17
+
18
+ /* Keep old class name working for backwards compat */
10
19
  .mld-experiment-selector__filters {
11
20
  display: flex;
12
21
  gap: 0.5rem;
@@ -14,14 +23,76 @@
14
23
 
15
24
  .mld-experiment-selector__search {
16
25
  flex: 1;
17
- min-width: 0;
26
+ min-width: 10rem;
18
27
  }
19
28
 
20
- .mld-experiment-selector__status-filter {
29
+ .mld-experiment-selector__status-filter,
30
+ .mld-experiment-selector__filter-select {
21
31
  flex-shrink: 0;
22
32
  width: 10rem;
23
33
  }
24
34
 
35
+ /* "Filters" toggle button */
36
+ .mld-experiment-selector__filters-toggle {
37
+ display: inline-flex;
38
+ align-items: center;
39
+ gap: 0.375rem;
40
+ padding: 0.375rem 0.625rem;
41
+ border: 1px solid var(--border-color);
42
+ border-radius: var(--mld-radius-sm);
43
+ background: var(--bg-primary);
44
+ color: var(--text-secondary);
45
+ font-size: 0.8125rem;
46
+ cursor: pointer;
47
+ white-space: nowrap;
48
+ position: relative;
49
+ transition: border-color 0.15s ease, color 0.15s ease;
50
+ }
51
+
52
+ .mld-experiment-selector__filters-toggle:hover {
53
+ border-color: var(--color-primary);
54
+ color: var(--text-primary);
55
+ }
56
+
57
+ .mld-experiment-selector__filters-toggle--active {
58
+ color: var(--color-primary);
59
+ border-color: var(--color-primary);
60
+ }
61
+
62
+ /* Active filter badge dot */
63
+ .mld-experiment-selector__filters-dot {
64
+ width: 6px;
65
+ height: 6px;
66
+ border-radius: 50%;
67
+ background-color: var(--color-primary, #3b82f6);
68
+ flex-shrink: 0;
69
+ }
70
+
71
+ /* Advanced filters row */
72
+ .mld-experiment-selector__filters-advanced {
73
+ padding-top: 0.25rem;
74
+ }
75
+
76
+ /* Group toggle checkbox label */
77
+ .mld-experiment-selector__group-toggle {
78
+ display: inline-flex;
79
+ align-items: center;
80
+ gap: 0.375rem;
81
+ font-size: 0.8125rem;
82
+ color: var(--text-secondary);
83
+ cursor: pointer;
84
+ white-space: nowrap;
85
+ user-select: none;
86
+ margin-left: auto;
87
+ }
88
+
89
+ .mld-experiment-selector__group-checkbox {
90
+ width: 14px;
91
+ height: 14px;
92
+ accent-color: var(--color-primary, #3b82f6);
93
+ cursor: pointer;
94
+ }
95
+
25
96
  /* Loading skeleton */
26
97
  .mld-experiment-selector__skeleton {
27
98
  display: flex;
@@ -66,6 +137,60 @@
66
137
  border-radius: var(--mld-radius);
67
138
  }
68
139
 
140
+ /* Project group header */
141
+ .mld-experiment-selector__group-header {
142
+ display: flex;
143
+ align-items: center;
144
+ gap: 0.5rem;
145
+ padding: 0.5rem 1rem;
146
+ background-color: var(--bg-secondary, var(--bg-primary));
147
+ border: none;
148
+ cursor: pointer;
149
+ width: 100%;
150
+ text-align: left;
151
+ font-size: 0.75rem;
152
+ font-weight: 600;
153
+ color: var(--text-secondary);
154
+ text-transform: uppercase;
155
+ letter-spacing: 0.03em;
156
+ transition: background-color 0.15s ease;
157
+ }
158
+
159
+ .mld-experiment-selector__group-header:hover {
160
+ background-color: var(--bg-hover);
161
+ }
162
+
163
+ .mld-experiment-selector__group-chevron {
164
+ flex-shrink: 0;
165
+ transition: transform 0.15s ease;
166
+ }
167
+
168
+ .mld-experiment-selector__group-chevron--collapsed {
169
+ transform: rotate(-90deg);
170
+ }
171
+
172
+ .mld-experiment-selector__group-name {
173
+ flex: 1;
174
+ min-width: 0;
175
+ overflow: hidden;
176
+ text-overflow: ellipsis;
177
+ white-space: nowrap;
178
+ }
179
+
180
+ .mld-experiment-selector__group-count {
181
+ display: inline-flex;
182
+ align-items: center;
183
+ justify-content: center;
184
+ min-width: 1.25rem;
185
+ height: 1.25rem;
186
+ padding: 0 0.375rem;
187
+ border-radius: 999px;
188
+ background-color: var(--bg-tertiary, var(--border-color));
189
+ font-size: 0.6875rem;
190
+ font-weight: 500;
191
+ color: var(--text-muted);
192
+ }
193
+
69
194
  /* Row */
70
195
  .mld-experiment-selector__row {
71
196
  display: flex;
@@ -134,3 +259,27 @@
134
259
  color: var(--text-muted);
135
260
  margin-top: 0.125rem;
136
261
  }
262
+
263
+ /* Footer with clear selection */
264
+ .mld-experiment-selector__footer {
265
+ display: flex;
266
+ justify-content: flex-start;
267
+ padding-top: 0.25rem;
268
+ }
269
+
270
+ .mld-experiment-selector__clear-btn {
271
+ padding: 0.25rem 0;
272
+ border: none;
273
+ background: none;
274
+ color: var(--text-muted);
275
+ font-size: 0.8125rem;
276
+ cursor: pointer;
277
+ text-decoration: underline;
278
+ text-decoration-color: transparent;
279
+ transition: color 0.15s ease, text-decoration-color 0.15s ease;
280
+ }
281
+
282
+ .mld-experiment-selector__clear-btn:hover {
283
+ color: var(--color-primary);
284
+ text-decoration-color: var(--color-primary);
285
+ }
@@ -604,6 +604,14 @@ export interface ResourceSpec {
604
604
 
605
605
  // Experiment selector types
606
606
  export type ExperimentStatus = 'planned' | 'ongoing' | 'completed'
607
+ export type DatePreset = 'last_7_days' | 'last_30_days' | 'last_90_days'
608
+ export type ExperimentSortField = 'created_at' | 'updated_at' | 'name' | 'status'
609
+
610
+ export interface ExperimentTypeOption {
611
+ value: string
612
+ label: string
613
+ color?: string
614
+ }
607
615
 
608
616
  export interface ExperimentSummary {
609
617
  id: number
@@ -613,6 +621,7 @@ export interface ExperimentSummary {
613
621
  experiment_type: string
614
622
  project?: string
615
623
  project_id?: number
624
+ project_name?: string
616
625
  notes?: string
617
626
  tags?: Record<string, unknown>
618
627
  created_at: string
@@ -629,6 +638,8 @@ export interface ExperimentFilters {
629
638
  search?: string
630
639
  status?: ExperimentStatus | null
631
640
  project?: string | null
641
+ experimentType?: string | null
642
+ datePreset?: DatePreset | null
632
643
  }
633
644
 
634
645
  // FitPanel types
@@ -128,6 +128,9 @@ export type {
128
128
  MoleculeData,
129
129
  // Experiment selector types
130
130
  ExperimentStatus,
131
+ DatePreset,
132
+ ExperimentSortField,
133
+ ExperimentTypeOption,
131
134
  ExperimentSummary,
132
135
  ExperimentListResponse,
133
136
  ExperimentFilters,