Refactor: improve code modularity
This commit is contained in:
@@ -0,0 +1,469 @@
|
||||
// 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);
|
||||
},
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user