// Path: Frontend/src/components/bootstrap/BootstrapWizard/BootstrapWizard.hook.ts import { createEffect, createMemo, createSignal, onCleanup } from "solid-js"; import { createStore } from "solid-js/store"; import { bootstrapPersonaDefinitions, bootstrapStepDefinitions, defaultAdminForm, defaultInstanceForm, defaultModeForm, defaultStructureForm, organizationalStructureDefaults, personalStructureDefaults, type AdminForm, type BootstrapPersona, type BootstrapPersonaDefinition, type BootstrapStepDefinition, type BootstrapStepKey, type InstanceForm, type ModeForm, type StructureForm, } from "./BootstrapWizard.data"; import { submitBootstrapStepRequest } from "./bootstrapWizard.api"; type AppShellBootstrapAdapter = { installation: () => { isBootstrapped?: boolean; materializationStatus?: string; materializationError?: string } | undefined; status: () => string; reload: () => Promise; }; export type BootstrapSubmissionState = { status: "idle" | "submitting" | "success" | "error"; error: string; }; export type MaterializationState = "not_started" | "pending" | "running" | "succeeded" | "failed"; export type FieldTooltipState = { text: string; left: number; top: number; placement: "top" | "bottom"; }; const initialSubmissionState = (): BootstrapSubmissionState => ({ status: "idle", error: "", }); const materializationPollIntervalMs = 2000; export const useBootstrapWizard = (appShellData: AppShellBootstrapAdapter) => { const [instanceForm, setInstanceForm] = createStore({ ...defaultInstanceForm }); const [modeForm, setModeForm] = createStore({ ...defaultModeForm }); const [adminForm, setAdminForm] = createStore({ ...defaultAdminForm }); const [structureForm, setStructureForm] = createStore({ ...defaultStructureForm }); const [selectedPersona, setSelectedPersona] = createSignal("enthusiast"); const [hasChosenPersona, setHasChosenPersona] = createSignal(false); const [stepState, setStepState] = createStore>({ persona: initialSubmissionState(), instance: initialSubmissionState(), mode: initialSubmissionState(), admin: initialSubmissionState(), structure: initialSubmissionState(), }); const [isBootstrapStateResolved, setIsBootstrapStateResolved] = createSignal(false); const [isBootstrapComplete, setIsBootstrapComplete] = createSignal(false); const [isWizardOpen, setIsWizardOpen] = createSignal(false); const [isFinishingBootstrapFlow, setIsFinishingBootstrapFlow] = createSignal(false); const [currentStepIndex, setCurrentStepIndex] = createSignal(0); const [fieldTooltip, setFieldTooltip] = createSignal(null); const installation = createMemo(() => appShellData.installation()); const materializationState = createMemo(() => { const status = installation()?.materializationStatus; switch (status) { case "pending": case "running": case "failed": case "succeeded": case "not_started": return status; default: return installation()?.isBootstrapped ? "succeeded" : "not_started"; } }); const isBootstrapPersisted = createMemo(() => installation()?.isBootstrapped ?? false); const isMaterializationInFlight = createMemo(() => materializationState() === "pending" || materializationState() === "running"); const hasMaterializationFailed = createMemo(() => materializationState() === "failed"); const showBootstrapFinishingState = createMemo(() => isFinishingBootstrapFlow() && (isMaterializationInFlight() || hasMaterializationFailed())); const materializationStatusLabel = createMemo(() => { switch (materializationState()) { case "pending": return "Materialization queued"; case "running": return "Materialization running"; case "failed": return "Materialization failed"; case "succeeded": return "Ready"; default: return "Not started"; } }); const materializationMessage = createMemo(() => { if (isMaterializationInFlight()) { return "Your bootstrap is saved. The worker is still creating the POSIX skeleton and rebuilding the app shell index."; } if (hasMaterializationFailed()) { return installation()?.materializationError || "Bootstrap saved, but background materialization did not finish cleanly."; } return ""; }); const personaDefinition = createMemo(() => bootstrapPersonaDefinitions.find((persona) => persona.id === selectedPersona()) ?? bootstrapPersonaDefinitions[0]!); const selectedPersonaIsAvailable = createMemo(() => personaDefinition().isAvailable); const usesCondensedBootstrapFlow = createMemo(() => selectedPersona() === "personal" || selectedPersona() === "team"); const activeBootstrapSteps = createMemo(() => { if (usesCondensedBootstrapFlow()) { return [bootstrapStepDefinitions[0]!, bootstrapStepDefinitions[2]!, bootstrapStepDefinitions[3]!]; } return bootstrapStepDefinitions; }); const activeWizardSteps = createMemo(() => activeBootstrapSteps().filter((step) => step.id !== "persona")); createEffect(() => { const defaults = personaDefinition().defaults; setInstanceForm({ protocol: defaults.protocol, access: defaults.access, host: defaults.host, }); setModeForm("mode", defaults.mode); setStructureForm({ departmentName: defaults.departmentName, teamName: defaults.teamName, projectName: defaults.projectName, }); }); createEffect(() => { if (modeForm.mode === "personal") { setStructureForm("departmentName", personalStructureDefaults.departmentName); setStructureForm("teamName", personalStructureDefaults.teamName); return; } if (structureForm.departmentName === personalStructureDefaults.departmentName) { setStructureForm("departmentName", organizationalStructureDefaults.departmentName); } if (structureForm.teamName === personalStructureDefaults.teamName) { setStructureForm("teamName", organizationalStructureDefaults.teamName); } }); const resetWizardState = (): void => { setSelectedPersona("enthusiast"); setHasChosenPersona(false); setInstanceForm({ ...defaultInstanceForm }); setModeForm({ ...defaultModeForm }); setAdminForm({ ...defaultAdminForm }); setStructureForm({ ...defaultStructureForm }); setStepState({ persona: initialSubmissionState(), instance: initialSubmissionState(), mode: initialSubmissionState(), admin: initialSubmissionState(), structure: initialSubmissionState(), }); setCurrentStepIndex(0); setIsFinishingBootstrapFlow(false); }; createEffect(() => { const shellStatus = appShellData.status(); if (shellStatus === "idle" || shellStatus === "loading") { return; } if (shellStatus !== "success") { return; } if (!isBootstrapPersisted()) { setIsFinishingBootstrapFlow(false); resetWizardState(); } setIsBootstrapComplete(isBootstrapPersisted() && !isMaterializationInFlight()); setIsWizardOpen(!isBootstrapPersisted() || showBootstrapFinishingState()); setIsBootstrapStateResolved(true); }); createEffect(() => { if (!isFinishingBootstrapFlow()) { return; } if (isMaterializationInFlight() || hasMaterializationFailed()) { return; } setIsFinishingBootstrapFlow(false); setIsWizardOpen(false); }); createEffect(() => { if (!isBootstrapPersisted() || !isMaterializationInFlight()) { return; } let cancelled = false; let timeoutId: number | undefined; const scheduleReload = (): void => { timeoutId = window.setTimeout(async () => { if (cancelled) { return; } await appShellData.reload(); if (!cancelled && isBootstrapPersisted() && isMaterializationInFlight()) { scheduleReload(); } }, materializationPollIntervalMs); }; scheduleReload(); onCleanup(() => { cancelled = true; if (timeoutId !== undefined) { window.clearTimeout(timeoutId); } }); }); const bootstrapNamePlaceholder = (): string => personaDefinition().defaults.namePlaceholder; const bootstrapStepCount = createMemo(() => activeWizardSteps().length); const currentStep = createMemo(() => activeBootstrapSteps()[currentStepIndex()] ?? activeBootstrapSteps()[0] ?? bootstrapStepDefinitions[0]!); const currentWizardStepIndex = createMemo(() => { const visibleIndex = activeWizardSteps().findIndex((step) => step.id === currentStep().id); return visibleIndex >= 0 ? visibleIndex : 0; }); const wizardProgressPercent = createMemo(() => { const totalSteps = bootstrapStepCount(); const activeIndex = Math.max(currentWizardStepIndex(), 0); if (totalSteps <= 1) { return 100; } return (activeIndex / (totalSteps - 1)) * 100; }); const wizardProgressFillWidth = createMemo(() => { if (currentStepIndex() <= 0) { return `${wizardProgressPercent()}%`; } return `calc(${wizardProgressPercent()}% + ((var(--control-size-md) - var(--space-2)) / 2))`; }); const currentStepState = createMemo(() => stepState[currentStep().id]); const isFirstStep = (): boolean => currentStepIndex() === 0; const isLastStep = (): boolean => currentStepIndex() === activeBootstrapSteps().length - 1; const canDismissWizard = (): boolean => isBootstrapPersisted() && !isMaterializationInFlight(); createEffect(() => { setCurrentStepIndex((index) => Math.min(index, activeBootstrapSteps().length - 1)); }); const submitStep = async (step: BootstrapStepKey, payload: unknown): Promise => { setStepState(step, { status: "submitting", error: "" }); try { await submitBootstrapStepRequest(step, payload); setStepState(step, { status: "success", error: "", }); return true; } catch (error) { setStepState(step, { status: "error", error: error instanceof Error ? error.message : `Bootstrap ${step} request failed.`, }); return false; } }; const payloadForStep = (step: BootstrapStepKey): unknown => { switch (step) { case "instance": return instanceForm; case "mode": return modeForm; case "admin": return adminForm; case "structure": return structureForm; } }; const applyPersonaSelection = (persona: BootstrapPersona): void => { const definition = bootstrapPersonaDefinitions.find((candidate) => candidate.id === persona); if (!definition?.isAvailable) { return; } setSelectedPersona(persona); setHasChosenPersona(true); setStepState("persona", { status: "success", error: "", }); setCurrentStepIndex((index) => Math.min(index + 1, activeBootstrapSteps().length - 1)); }; const statusLabel = (state: BootstrapSubmissionState): string => { switch (state.status) { case "submitting": return "Sending"; case "error": return "Request failed"; default: return ""; } }; const submitCurrentStep = async (): Promise => { const step = currentStep().id; if (step === "persona") { applyPersonaSelection(selectedPersona()); return; } if (step === "mode" && usesCondensedBootstrapFlow() && stepState.instance.status !== "success") { const didPersistInstanceDefaults = await submitStep("instance", instanceForm); if (!didPersistInstanceDefaults) { return; } } const didSucceed = await submitStep(step, payloadForStep(step)); if (!didSucceed) { return; } if (step === "admin" && usesCondensedBootstrapFlow()) { const didPersistStructureDefaults = await submitStep("structure", structureForm); if (!didPersistStructureDefaults) { return; } } if (isLastStep()) { await appShellData.reload(); const shouldShowFinishingState = isBootstrapPersisted() && (isMaterializationInFlight() || hasMaterializationFailed()); setIsFinishingBootstrapFlow(shouldShowFinishingState); setIsBootstrapComplete(isBootstrapPersisted() && !isMaterializationInFlight()); setIsWizardOpen(!isBootstrapPersisted() || shouldShowFinishingState); setIsBootstrapStateResolved(true); return; } setCurrentStepIndex((index) => Math.min(index + 1, activeBootstrapSteps().length - 1)); }; const showFieldTooltip = (target: HTMLElement, text: string): void => { const rect = target.getBoundingClientRect(); const placement = rect.top > 96 ? "top" : "bottom"; const viewportPadding = 20; const left = Math.min(Math.max(rect.left + rect.width / 2, viewportPadding), window.innerWidth - viewportPadding); const top = placement === "top" ? rect.top - 10 : rect.bottom + 10; setFieldTooltip({ text, left, top, placement }); }; const hideFieldTooltip = (): void => { setFieldTooltip(null); }; const stepStatusLabel = (step: BootstrapStepDefinition): string => { const state = stepState[step.id]; if (state.status === "success") { return "Done"; } if (state.status === "error") { return "Needs retry"; } return ""; }; return { instanceForm, setInstanceForm, modeForm, setModeForm, adminForm, setAdminForm, structureForm, setStructureForm, selectedPersona, setSelectedPersona, hasChosenPersona, stepState, isBootstrapStateResolved, isBootstrapComplete, isWizardOpen, setIsWizardOpen, isFinishingBootstrapFlow, setIsFinishingBootstrapFlow, fieldTooltip, materializationState, isMaterializationInFlight, hasMaterializationFailed, showBootstrapFinishingState, materializationStatusLabel, materializationMessage, personaDefinition, selectedPersonaIsAvailable, usesCondensedBootstrapFlow, activeWizardSteps, bootstrapNamePlaceholder, bootstrapStepCount, currentStep, currentWizardStepIndex, wizardProgressFillWidth, currentStepState, isFirstStep, canDismissWizard, resetWizardState, handleCurrentStepSubmit: (event: SubmitEvent & { currentTarget: HTMLFormElement; target: Element }): void => { event.preventDefault(); void submitCurrentStep(); }, applyPersonaSelection, statusLabel, showFieldTooltip, hideFieldTooltip, stepStatusLabel, navigateBack: (): void => { setCurrentStepIndex((index) => Math.max(index - 1, 0)); }, navigateToVisibleStep: (index: number): void => { setCurrentStepIndex(index + 1); }, }; };