import { ref, reactive, computed, watch, type Ref, type ComputedRef } from 'vue' import { useApi } from './useApi' import { useDebouncedWatch } from './useDebouncedWatch' import { useRequestSyncState } from './useRequestSyncState' import { datePresetToISO } from './experiment-utils' import type { ExperimentSummary, ExperimentListResponse, ExperimentFilters, ExperimentTypeOption, ExperimentSortField, PlatformContext, SelectOption, } from '../types' function getPlatformContext(): PlatformContext | undefined { if (typeof window === 'undefined') return undefined return (window as unknown as { __MINT_PLATFORM__?: PlatformContext }).__MINT_PLATFORM__ } function getPlatformApiUrl(): string | undefined { return getPlatformContext()?.platformApiUrl } export interface UseExperimentSelectorOptions { experimentType?: string allowedExperimentTypes?: string[] | null apiBaseUrl?: string limit?: number immediate?: boolean } export interface UseExperimentSelectorReturn { /** Current page of fetched experiment rows. */ experiments: Ref /** Total matching experiment count reported by the platform. */ total: Ref /** Currently selected experiment, or null. */ selectedExperiment: Ref /** Mutable filters used for the next fetch. */ filters: ExperimentFilters /** Whether experiment rows are currently loading. */ isLoading: Ref /** Error message from the last failed fetch, or null. */ error: Ref /** Timestamp of the last successful experiment fetch, or null. */ lastLoadedAt: Ref /** Current zero-based page index. */ page: Ref /** Whether another page can be loaded. */ hasMore: ComputedRef // Sort /** Combined sort key in the form `:`. */ sortKey: Ref // Filter options /** Available experiment type options for filter UI. */ experimentTypes: Ref /** Available project options for filter UI. */ projects: Ref[]> // Grouped view /** Fetched experiments grouped by project name. */ groupedByProject: ComputedRef<[string, ExperimentSummary[]][]> // Methods /** Fetch the current page with the current filters. */ fetch: () => Promise /** Fetch and append the next page when available. */ loadMore: () => Promise /** Clear fetched rows and refetch page zero. */ reset: () => void /** Select an experiment. */ select: (experiment: ExperimentSummary) => void /** Clear selection and filters. */ clear: () => void /** Fetch experiment type and project filter options. */ fetchFilterOptions: () => Promise } /** Fetches a paginated, filtered experiment list from the platform API for picker and selector UIs. */ export function useExperimentSelector( options: UseExperimentSelectorOptions = {}, ): UseExperimentSelectorReturn { const { limit = 100, immediate = false, experimentType, allowedExperimentTypes: optionAllowedExperimentTypes, apiBaseUrl, } = options const platformBase = apiBaseUrl ?? getPlatformApiUrl() const api = useApi() const experiments = ref([]) const total = ref(0) const selectedExperiment = ref(null) const request = useRequestSyncState('Failed to fetch experiments') const isLoading = request.loading const error = request.error const lastLoadedAt = request.lastLoadedAt const page = ref(0) let fetchGeneration = 0 // Sort: combined key like "created_at:desc" const sortKey = ref('created_at:desc') // Filter option data (fetched once, cached) const experimentTypes = ref([]) const projects = ref[]>([]) let filterOptionsFetched = false const hasMore = computed(() => experiments.value.length < total.value) const filters: ExperimentFilters = reactive({ search: undefined, status: undefined, project: undefined, experimentType: undefined, datePreset: undefined, }) function normalizeExperimentTypes(values: string[] | null | undefined): string[] | null { if (values == null) return null return [...new Set(values.filter((value): value is string => typeof value === 'string' && value.length > 0))] } function currentAllowedExperimentTypes(): string[] | null { if (optionAllowedExperimentTypes !== undefined) { return normalizeExperimentTypes(optionAllowedExperimentTypes) } return normalizeExperimentTypes(getPlatformContext()?.allowedExperimentTypes) } function parseSortKey(): { sortBy: ExperimentSortField; sortOrder: 'asc' | 'desc' } { const [field, order] = sortKey.value.split(':') return { sortBy: (field || 'created_at') as ExperimentSortField, sortOrder: (order || 'desc') as 'asc' | 'desc', } } type ExperimentTypeScope = | { kind: 'all' } | { kind: 'blocked' } | { kind: 'single'; experimentType: string } | { kind: 'multi'; experimentTypes: string[] } function resolveExperimentTypeScope(): ExperimentTypeScope { const allowedTypes = currentAllowedExperimentTypes() const selectedType = experimentType ?? filters.experimentType ?? undefined if (selectedType) { if (allowedTypes && !allowedTypes.includes(selectedType)) { return { kind: 'blocked' } } return { kind: 'single', experimentType: selectedType } } if (allowedTypes) { if (allowedTypes.length === 0) return { kind: 'blocked' } if (allowedTypes.length === 1) return { kind: 'single', experimentType: allowedTypes[0] } return { kind: 'multi', experimentTypes: allowedTypes } } return { kind: 'all' } } function buildExperimentListUrl(typeFilter: string | undefined, skip: number, pageLimit: number): string { const params = new URLSearchParams() if (typeFilter) params.set('experiment_type', typeFilter) if (filters.status) params.set('status', filters.status) if (filters.search) params.set('search', filters.search) if (filters.project) params.set('project', filters.project) const { sortBy, sortOrder } = parseSortKey() params.set('sort_by', sortBy) params.set('sort_order', sortOrder) if (filters.datePreset) { params.set('created_after', datePresetToISO(filters.datePreset)) } params.set('limit', String(pageLimit)) params.set('skip', String(skip)) const query = params.toString() const base = platformBase ?? '' return `${base}/experiments${query ? `?${query}` : ''}` } function sortExperiments(items: ExperimentSummary[]): ExperimentSummary[] { const { sortBy, sortOrder } = parseSortKey() const direction = sortOrder === 'desc' ? -1 : 1 return [...items].sort((a, b) => { const aValue = a[sortBy] ?? '' const bValue = b[sortBy] ?? '' if (aValue < bValue) return -1 * direction if (aValue > bValue) return 1 * direction return 0 }) } async function fetchExperimentList(typeFilter: string | undefined, skip: number, pageLimit: number) { return await api.get(buildExperimentListUrl(typeFilter, skip, pageLimit)) } async function fetchScopedExperimentPage( requestedPage: number, ): Promise { const scope = resolveExperimentTypeScope() const pageSkip = requestedPage * limit if (scope.kind === 'blocked') { return { experiments: [], total: 0 } } if (scope.kind === 'multi') { const perTypeLimit = pageSkip + limit const responses = await Promise.all( scope.experimentTypes.map(type => fetchExperimentList(type, 0, perTypeLimit)), ) const merged = sortExperiments(responses.flatMap(response => response.experiments)) return { experiments: merged.slice(pageSkip, pageSkip + limit), total: responses.reduce((sum, response) => sum + response.total, 0), } } return await fetchExperimentList( scope.kind === 'single' ? scope.experimentType : undefined, pageSkip, limit, ) } async function fetchExperiments(): Promise { const generation = ++fetchGeneration const requestedPage = page.value isLoading.value = true request.clearError() try { const data = await fetchScopedExperimentPage(requestedPage) if (generation !== fetchGeneration) return if (requestedPage === 0) { experiments.value = data.experiments } else { experiments.value = [...experiments.value, ...data.experiments] } total.value = data.total request.markLoaded() } catch (err) { if (generation !== fetchGeneration) return request.setError(err, 'Failed to fetch experiments') if (requestedPage === 0) { experiments.value = [] total.value = 0 } } finally { if (generation === fetchGeneration) { isLoading.value = false } } } function invalidateFetch(): void { fetchGeneration += 1 isLoading.value = false } async function fetchFilterOptions(): Promise { if (filterOptionsFetched) return filterOptionsFetched = true const base = platformBase ?? '' const [typesRes, projectsRes] = await Promise.allSettled([ api.get>(`${base}/experiments/experiment-types`), api.get<{ projects: Array<{ id: number; name: string }>; total: number }>(`${base}/projects`), ]) if (typesRes.status === 'fulfilled' && Array.isArray(typesRes.value)) { const allowedTypes = currentAllowedExperimentTypes() const allowedSet = allowedTypes ? new Set(allowedTypes) : null experimentTypes.value = typesRes.value .filter(t => !allowedSet || allowedSet.has(t.value)) .map(t => ({ value: t.value, label: t.label, color: t.color, })) if (filters.experimentType && allowedSet && !allowedSet.has(filters.experimentType)) { filters.experimentType = undefined } } if (projectsRes.status === 'fulfilled' && projectsRes.value?.projects && Array.isArray(projectsRes.value.projects)) { projects.value = projectsRes.value.projects.map(p => ({ value: p.name, label: p.name, })) } } async function loadMore(): Promise { if (!hasMore.value || isLoading.value) return page.value++ await fetchExperiments() } function reset(): void { page.value = 0 experiments.value = [] total.value = 0 fetchExperiments() } function select(experiment: ExperimentSummary): void { selectedExperiment.value = experiment } function clear(): void { invalidateFetch() selectedExperiment.value = null filters.search = undefined filters.status = undefined filters.project = undefined filters.experimentType = undefined filters.datePreset = undefined sortKey.value = 'created_at:desc' page.value = 0 request.clearError() } // Group experiments by project (client-side) const groupedByProject = computed<[string, ExperimentSummary[]][]>(() => { const groups = new Map() for (const exp of experiments.value) { const key = exp.project_name ?? exp.project ?? 'No project' const list = groups.get(key) if (list) { list.push(exp) } else { groups.set(key, [exp]) } } // Sort alphabetically, "No project" last return [...groups.entries()].sort(([a], [b]) => { if (a === 'No project') return 1 if (b === 'No project') return -1 return a.localeCompare(b) }) }) watch( () => [ filters.search, filters.status, filters.project, filters.experimentType, filters.datePreset, sortKey.value, ], () => invalidateFetch(), { flush: 'sync' }, ) const searchWatch = useDebouncedWatch( () => filters.search, () => { page.value = 0 fetchExperiments() }, { delay: 300 }, ) // Immediate watch on discrete filters and sort (cancel any pending search debounce) watch( () => [filters.status, filters.project, filters.experimentType, filters.datePreset, sortKey.value], () => { searchWatch.cancel() page.value = 0 fetchExperiments() }, ) if (immediate) { fetchExperiments() } return { experiments, total, selectedExperiment, filters, isLoading, error, lastLoadedAt, page, hasMore, sortKey, experimentTypes, projects, groupedByProject, fetch: fetchExperiments, loadMore, reset, select, clear, fetchFilterOptions, } }