import { ref, computed, provide, toValue, watch, onScopeDispose, type Ref, type ComputedRef, type InjectionKey, } from 'vue' import type { ExperimentSummary, ExperimentStatus } from '../types' import { useExperimentStore } from '../stores/experiment' import { resolveExperimentCode } from './experiment-utils' export interface UseAppExperimentOptions { /** * Called when the shared store adopts an experiment — a modal selection or a * resolved deep link. The record always comes from the platform. */ onSelect?: (experiment: ExperimentSummary) => void | Promise onSave?: () => string | null | Promise onDetach?: () => void saveDisabled?: Ref | ComputedRef saveDisabledMessage?: string | Ref | ComputedRef } export interface AppExperimentPopoverBinding { experimentName?: string experimentCode?: string experimentStatus?: ExperimentStatus /** An experiment is bound but its record has not arrived yet. */ isResolving: boolean /** Resolution failed; the binding is kept so the user can retry or detach. */ error?: string showSave: boolean showDetach: boolean saveDisabled: boolean saveDisabledMessage?: string saveLoading: boolean saveSuccessMessage?: string } export interface AppExperimentSelectorModalBinding { modelValue: boolean currentExperimentId: number | null } export interface UseAppExperimentReturn { experimentName: ComputedRef experimentCode: ComputedRef experimentStatus: ComputedRef experimentId: ComputedRef isResolving: ComputedRef error: ComputedRef popover: ComputedRef selectorModal: ComputedRef openModal: () => void closeModal: () => void handleSave: () => Promise handleDetach: () => void } export interface AppExperimentState { experimentName: ComputedRef experimentCode: ComputedRef experimentStatus: ComputedRef experimentId: ComputedRef isResolving: ComputedRef error: ComputedRef showModal: Ref saveLoading: Ref saveSuccessMessage: Ref showSave: Ref showDetach: ComputedRef closeModal: () => void saveDisabled: ComputedRef saveDisabledMessage: ComputedRef popover: ComputedRef selectorModal: ComputedRef openModal: () => void handleSave: () => Promise handleDetach: () => void } export const APP_EXPERIMENT_KEY: InjectionKey = Symbol('app-experiment') /** * Projects the current experiment from the shared store into an AppTopBar app * shell, and owns the shell-local concerns the store has no opinion on: the * selector modal's open state and the save flow. * * The composable never writes a selection of its own. `ExperimentSelectorModal` * commits to the store and this reads back, so a displayed name can only ever * have come from the platform. * * @example Recommended * ```ts * const appExperiment = useAppExperiment({ * onSelect: experiment => loadExperiment(experiment.id), * }) * ``` */ export function useAppExperiment(options: UseAppExperimentOptions = {}): UseAppExperimentReturn { const store = useExperimentStore() const showModal = ref(false) const saveLoading = ref(false) const saveSuccessMessage = ref() let successTimer: ReturnType | null = null const experimentName = computed(() => store.current?.name) const experimentCode = computed(() => store.current ? resolveExperimentCode(store.current) : undefined, ) const experimentStatus = computed(() => store.current?.status) // Set while resolving and after a failed resolve, so the shell can tell // "nothing selected" apart from "selected but not resolved". const experimentId = computed(() => store.currentId) const isResolving = computed(() => store.isResolving) const error = computed(() => store.error) const showSave = ref(!!options.onSave) const showDetach = computed(() => experimentId.value !== null) const saveDisabled = computed(() => toValue(options.saveDisabled) ?? false) const saveDisabledMessage = computed(() => toValue(options.saveDisabledMessage)) const popover = computed(() => ({ experimentName: experimentName.value, experimentCode: experimentCode.value, experimentStatus: experimentStatus.value, isResolving: isResolving.value, error: error.value ?? undefined, showSave: showSave.value, showDetach: showDetach.value, saveDisabled: saveDisabled.value, saveDisabledMessage: saveDisabledMessage.value, saveLoading: saveLoading.value, saveSuccessMessage: saveSuccessMessage.value, })) const selectorModal = computed(() => ({ modelValue: showModal.value, currentExperimentId: experimentId.value, })) if (options.onSelect) { watch( () => store.current, (experiment) => { if (experiment) void options.onSelect?.(experiment) }, ) } function openModal() { showModal.value = true } function closeModal() { showModal.value = false } async function handleSave() { if (!options.onSave || saveLoading.value) return saveLoading.value = true try { const message = await options.onSave() if (message) { saveSuccessMessage.value = message if (successTimer) clearTimeout(successTimer) successTimer = setTimeout(() => { saveSuccessMessage.value = undefined successTimer = null }, 3000) } } finally { saveLoading.value = false } } function handleDetach() { store.clear() options.onDetach?.() } onScopeDispose(() => { if (successTimer) clearTimeout(successTimer) }) const state: AppExperimentState = { experimentName, experimentCode, experimentStatus, experimentId, isResolving, error, showModal, saveLoading, saveSuccessMessage, showSave, showDetach, saveDisabled, saveDisabledMessage, popover, selectorModal, openModal, closeModal, handleSave, handleDetach, } provide(APP_EXPERIMENT_KEY, state) return { experimentName, experimentCode, experimentStatus, experimentId, isResolving, error, popover, selectorModal, openModal, closeModal, handleSave, handleDetach, } }