470 lines
14 KiB
TypeScript
470 lines
14 KiB
TypeScript
// 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<void>;
|
|
};
|
|
|
|
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<InstanceForm>({ ...defaultInstanceForm });
|
|
const [modeForm, setModeForm] = createStore<ModeForm>({ ...defaultModeForm });
|
|
const [adminForm, setAdminForm] = createStore<AdminForm>({ ...defaultAdminForm });
|
|
const [structureForm, setStructureForm] = createStore<StructureForm>({ ...defaultStructureForm });
|
|
const [selectedPersona, setSelectedPersona] = createSignal<BootstrapPersona>("enthusiast");
|
|
const [hasChosenPersona, setHasChosenPersona] = createSignal(false);
|
|
const [stepState, setStepState] = createStore<Record<BootstrapStepKey, BootstrapSubmissionState>>({
|
|
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<FieldTooltipState | null>(null);
|
|
|
|
const installation = createMemo(() => appShellData.installation());
|
|
const materializationState = createMemo<MaterializationState>(() => {
|
|
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<BootstrapPersonaDefinition>(() => bootstrapPersonaDefinitions.find((persona) => persona.id === selectedPersona()) ?? bootstrapPersonaDefinitions[0]!);
|
|
const selectedPersonaIsAvailable = createMemo(() => personaDefinition().isAvailable);
|
|
const usesCondensedBootstrapFlow = createMemo(() => selectedPersona() === "personal" || selectedPersona() === "team");
|
|
const activeBootstrapSteps = createMemo<readonly BootstrapStepDefinition[]>(() => {
|
|
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<BootstrapStepDefinition>(() => 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<BootstrapSubmissionState>(() => 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<boolean> => {
|
|
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<void> => {
|
|
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);
|
|
},
|
|
};
|
|
};
|