diff --git a/web/package-lock.json b/web/package-lock.json index 937bd4de77..f7f8895370 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -5142,16 +5142,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/longest-streak": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", - "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, "node_modules/loupe": { "version": "3.1.4", "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.1.4.tgz", diff --git a/web/src/App.tsx b/web/src/App.tsx index 4599a966c7..a0d170b2a4 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -3,7 +3,7 @@ import { LinuxConfigProvider } from "./contexts/LinuxConfigContext"; import { KubernetesConfigProvider } from "./contexts/KubernetesConfigContext"; import { SettingsProvider } from "./contexts/SettingsContext"; import { WizardProvider } from "./contexts/WizardModeContext"; -import { BrandingProvider } from "./contexts/BrandingContext"; +import { InitialStateProvider } from "./contexts/InitialStateContext"; import { AuthProvider } from "./contexts/AuthContext"; import ConnectionMonitor from "./components/common/ConnectionMonitor"; import InstallWizard from "./components/wizard/InstallWizard"; @@ -13,12 +13,12 @@ import { getQueryClient } from "./query-client"; function App() { const queryClient = getQueryClient(); return ( - - - - - - + + + + + +
@@ -30,18 +30,17 @@ function App() { } /> - } />
-
-
-
-
-
- -
+ + + + + + + ); } diff --git a/web/src/components/common/Logo.tsx b/web/src/components/common/Logo.tsx index a25ca1099c..4b4cf8519d 100644 --- a/web/src/components/common/Logo.tsx +++ b/web/src/components/common/Logo.tsx @@ -1,9 +1,9 @@ import React from 'react'; -import { useBranding } from '../../contexts/BrandingContext'; +import { useInitialState } from '../../contexts/InitialStateContext'; export const AppIcon: React.FC<{ className?: string }> = ({ className = 'w-6 h-6' }) => { - const { icon } = useBranding(); + const { icon } = useInitialState(); if (!icon) { return
; } diff --git a/web/src/components/wizard/completion/KubernetesCompletionStep.tsx b/web/src/components/wizard/completion/KubernetesCompletionStep.tsx index 094e46a660..f4ae8563f2 100644 --- a/web/src/components/wizard/completion/KubernetesCompletionStep.tsx +++ b/web/src/components/wizard/completion/KubernetesCompletionStep.tsx @@ -1,15 +1,15 @@ -import React, {useState} from "react"; +import React, { useState } from "react"; import Card from "../../common/Card"; import Button from "../../common/Button"; import { useKubernetesConfig } from "../../../contexts/KubernetesConfigContext"; -import { useBranding } from "../../../contexts/BrandingContext"; +import { useInitialState } from "../../../contexts/InitialStateContext"; import { useSettings } from "../../../contexts/SettingsContext"; import { CheckCircle, ClipboardCheck, Copy, Terminal } from "lucide-react"; const KubernetesCompletionStep: React.FC = () => { const [copied, setCopied] = useState(false); const { config } = useKubernetesConfig(); - const { title } = useBranding(); + const { title } = useInitialState(); const { settings } = useSettings(); const themeColor = settings.themeColor; diff --git a/web/src/components/wizard/completion/LinuxCompletionStep.tsx b/web/src/components/wizard/completion/LinuxCompletionStep.tsx index 74e90eb66d..a097433d3c 100644 --- a/web/src/components/wizard/completion/LinuxCompletionStep.tsx +++ b/web/src/components/wizard/completion/LinuxCompletionStep.tsx @@ -2,13 +2,13 @@ import React from "react"; import Card from "../../common/Card"; import Button from "../../common/Button"; import { useLinuxConfig } from "../../../contexts/LinuxConfigContext"; -import { useBranding } from "../../../contexts/BrandingContext"; +import { useInitialState } from "../../../contexts/InitialStateContext"; import { useSettings } from "../../../contexts/SettingsContext"; import { CheckCircle, ExternalLink } from "lucide-react"; const LinuxCompletionStep: React.FC = () => { const { config } = useLinuxConfig(); - const { title } = useBranding(); + const { title } = useInitialState(); const { settings } = useSettings(); const themeColor = settings.themeColor; diff --git a/web/src/components/wizard/setup/LinuxSetupStep.tsx b/web/src/components/wizard/setup/LinuxSetupStep.tsx index 21c003ce78..4ac67c545b 100644 --- a/web/src/components/wizard/setup/LinuxSetupStep.tsx +++ b/web/src/components/wizard/setup/LinuxSetupStep.tsx @@ -3,7 +3,7 @@ import Input from "../../common/Input"; import Select from "../../common/Select"; import Button from "../../common/Button"; import Card from "../../common/Card"; -import { useBranding } from "../../../contexts/BrandingContext"; +import { useInitialState } from "../../../contexts/InitialStateContext"; import { useLinuxConfig } from "../../../contexts/LinuxConfigContext"; import { useWizard } from "../../../contexts/WizardModeContext"; import { useQuery, useMutation } from "@tanstack/react-query"; @@ -19,17 +19,17 @@ import { ChevronDown, ChevronLeft, ChevronRight } from "lucide-react"; * - Error formatting: formatErrorMessage("adminConsolePort invalid") -> "Admin Console Port invalid" */ const fieldNames = { - adminConsolePort: "Admin Console Port", - dataDirectory: "Data Directory", - localArtifactMirrorPort: "Local Artifact Mirror Port", - httpProxy: "HTTP Proxy", - httpsProxy: "HTTPS Proxy", - noProxy: "Proxy Bypass List", - networkInterface: "Network Interface", - podCidr: "Pod CIDR", - serviceCidr: "Service CIDR", - globalCidr: "Reserved Network Range (CIDR)", - cidr: "CIDR", + adminConsolePort: "Admin Console Port", + dataDirectory: "Data Directory", + localArtifactMirrorPort: "Local Artifact Mirror Port", + httpProxy: "HTTP Proxy", + httpsProxy: "HTTPS Proxy", + noProxy: "Proxy Bypass List", + networkInterface: "Network Interface", + podCidr: "Pod CIDR", + serviceCidr: "Service CIDR", + globalCidr: "Reserved Network Range (CIDR)", + cidr: "CIDR", } interface LinuxSetupStepProps { @@ -49,7 +49,7 @@ interface ConfigError extends Error { const LinuxSetupStep: React.FC = ({ onNext, onBack }) => { const { config, updateConfig } = useLinuxConfig(); const { text } = useWizard(); - const { title } = useBranding(); + const { title } = useInitialState(); const [showAdvanced, setShowAdvanced] = useState(false); const [error, setError] = useState(null); const { token } = useAuth(); @@ -269,9 +269,9 @@ const LinuxSetupStep: React.FC = ({ onNext, onBack }) => { options={[ ...(availableNetworkInterfaces.length > 0 ? availableNetworkInterfaces.map((iface: string) => ({ - value: iface, - label: iface, - })) + value: iface, + label: iface, + })) : []), ]} helpText={`Network interface to use for ${title}`} @@ -296,7 +296,7 @@ const LinuxSetupStep: React.FC = ({ onNext, onBack }) => { {error && (
- {submitError?.errors && submitError.errors.length > 0 + {submitError?.errors && submitError.errors.length > 0 ? "Please fix the errors in the form above before proceeding." : error } @@ -326,13 +326,13 @@ const LinuxSetupStep: React.FC = ({ onNext, onBack }) => { * @returns The formatted error message with replaced field names */ export function formatErrorMessage(message: string) { - let finalMsg = message - for (const [field, fieldName] of Object.entries(fieldNames)) { - // Case-insensitive regex that matches whole words only - // Example: "podCidr", "PodCidr", "PODCIDR" all become "Pod CIDR" - finalMsg = finalMsg.replace(new RegExp(`\\b${field}\\b`, 'gi'), fieldName) - } - return finalMsg + let finalMsg = message + for (const [field, fieldName] of Object.entries(fieldNames)) { + // Case-insensitive regex that matches whole words only + // Example: "podCidr", "PodCidr", "PODCIDR" all become "Pod CIDR" + finalMsg = finalMsg.replace(new RegExp(`\\b${field}\\b`, 'gi'), fieldName) + } + return finalMsg } export default LinuxSetupStep; diff --git a/web/src/contexts/AuthContext.tsx b/web/src/contexts/AuthContext.tsx index 27d84e10c2..abccaaab42 100644 --- a/web/src/contexts/AuthContext.tsx +++ b/web/src/contexts/AuthContext.tsx @@ -1,5 +1,6 @@ import React, { createContext, useContext, useState, useEffect } from "react"; import { handleUnauthorized } from "../utils/auth"; +import { useInitialState } from "./InitialStateContext"; interface AuthContextType { token: string | null; @@ -32,17 +33,15 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children } setTokenState(newToken); }; + // Get the installation target from initial state + const { installTarget } = useInitialState() // Check token validity on mount and when token changes useEffect(() => { if (token) { - // Get the installation target from initial state - const initialState = window.__INITIAL_STATE__ || {}; - const target = initialState.installTarget; - // Make a request to any authenticated endpoint to check token validity // Use the correct target-specific endpoint based on installation target - fetch(`/api/${target}/install/installation/config`, { + fetch(`/api/${installTarget}/install/installation/config`, { headers: { Authorization: `Bearer ${token}`, }, diff --git a/web/src/contexts/BrandingContext.tsx b/web/src/contexts/BrandingContext.tsx deleted file mode 100644 index 859e5ae30a..0000000000 --- a/web/src/contexts/BrandingContext.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import React, { createContext, useContext } from "react"; - -interface Branding { - title: string; - icon?: string; -} - -export const BrandingContext = createContext({ title: "My App" }); - -export const useBranding = () => { - const context = useContext(BrandingContext); - if (!context) { - throw new Error("useBranding must be used within a BrandingProvider"); - } - return context; -}; - -export const BrandingProvider: React.FC<{ children: React.ReactNode }> = ({ - children, -}) => { - // __INITIAL_STATE__ is a global variable that can be set by the server-side rendering process - // as a way to pass initial data to the client. - const initialState = window.__INITIAL_STATE__ || {}; - - const branding = { - title: initialState.title || "My App", - icon: initialState.icon, - }; - - return ( - - {children} - - ); -}; diff --git a/web/src/contexts/InitialStateContext.tsx b/web/src/contexts/InitialStateContext.tsx new file mode 100644 index 0000000000..e1efa29fda --- /dev/null +++ b/web/src/contexts/InitialStateContext.tsx @@ -0,0 +1,40 @@ +import React, { createContext, useContext } from "react"; +import { InitialState } from "../types"; +import { InstallationTarget, isInstallationTarget } from "../types/installation-target"; + +export const InitialStateContext = createContext({ title: "My App", installTarget: "linux" }); + +export const useInitialState = () => { + const context = useContext(InitialStateContext); + if (!context) { + throw new Error("useInitialState must be used within a InitialStateProvider"); + } + return context; +}; + +function parseInstallationTarget(target: string): InstallationTarget { + if (isInstallationTarget(target)) { + return target; + } + throw new Error(`Invalid installation target: ${target}`); +} + +export const InitialStateProvider: React.FC<{ children: React.ReactNode }> = ({ + children, +}) => { + // __INITIAL_STATE__ is a global variable that can be set by the server-side rendering process + // as a way to pass initial data to the client. + const initialState = window.__INITIAL_STATE__ || {}; + + const state = { + title: initialState.title || "My App", + icon: initialState.icon, + installTarget: parseInstallationTarget(initialState.installTarget || "linux"), // default to "linux" if not provided + }; + + return ( + + {children} + + ); +}; diff --git a/web/src/contexts/WizardModeContext.tsx b/web/src/contexts/WizardModeContext.tsx index 35fbdab8ea..4d33e9e06c 100644 --- a/web/src/contexts/WizardModeContext.tsx +++ b/web/src/contexts/WizardModeContext.tsx @@ -1,8 +1,8 @@ import React, { createContext, useContext } from "react"; -import { useBranding } from "./BrandingContext"; +import { useInitialState } from "./InitialStateContext"; +import { InstallationTarget } from "../types/installation-target"; export type WizardMode = "install" | "upgrade"; -export type WizardTarget = "linux" | "kubernetes"; interface WizardText { title: string; @@ -59,7 +59,7 @@ const getTextVariations = (isLinux: boolean, title: string): Record(undefined); export const WizardProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { - // __INITIAL_STATE__ is a global variable that can be set by the server-side rendering process - // as a way to pass initial data to the client. - const initialState = window.__INITIAL_STATE__ || {}; - const target: WizardTarget = initialState.installTarget as WizardTarget; + const { title, installTarget } = useInitialState(); const mode = "install"; // TODO: get mode from initial state - - const { title } = useBranding(); - const isLinux = target === "linux"; + const isLinux = installTarget === "linux"; const text = getTextVariations(isLinux, title)[mode]; - return {children}; + return {children}; }; export const useWizard = (): WizardModeContextType => { diff --git a/web/src/contexts/tests/InitialStateContext.test.tsx b/web/src/contexts/tests/InitialStateContext.test.tsx new file mode 100644 index 0000000000..a39e0b5fea --- /dev/null +++ b/web/src/contexts/tests/InitialStateContext.test.tsx @@ -0,0 +1,258 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { InitialStateContext, useInitialState, InitialStateProvider } from "../InitialStateContext"; +import { InstallationTarget } from "../../types/installation-target"; + +type CustomWindow = typeof window & { + __INITIAL_STATE__?: unknown; +} + +describe("InitialStateContext", () => { + const originalWindow = global.window; + + beforeEach(() => { + // Reset window mock before each test + global.window = { + ...originalWindow, + __INITIAL_STATE__: undefined, + } as CustomWindow; + }); + + afterEach(() => { + // Restore original window + global.window = originalWindow; + vi.clearAllMocks(); + }); + + describe("useInitialState hook", () => { + it("returns context value when used within provider", () => { + const mockContext = { + title: "Test App", + icon: "test-icon.png", + installTarget: "linux" as InstallationTarget, + }; + + const TestComponent = () => { + const state = useInitialState(); + return
{state.title}
; + }; + + render( + + + + ); + + expect(screen.getByText("Test App")).toBeInTheDocument(); + }); + }); + + describe("parseInstallationTarget function", () => { + it("linux is a valid installation target", () => { + + const TestComponent = () => { + const state = useInitialState(); + return
{state.installTarget}
; + }; + + // Set up window.__INITIAL_STATE__ with valid target + global.window.__INITIAL_STATE__ = { + installTarget: "linux", + }; + + render( + + + + ); + + expect(screen.getByText("linux")).toBeInTheDocument(); + }); + + it("kubernetes is a valid installation target", () => { + + const TestComponent = () => { + const state = useInitialState(); + return
{state.installTarget}
; + }; + + // Set up window.__INITIAL_STATE__ with valid target + global.window.__INITIAL_STATE__ = { + installTarget: "kubernetes", + }; + + render( + + + + ); + + expect(screen.getByText("kubernetes")).toBeInTheDocument(); + }); + + it("throws error for invalid installation target", () => { + + // Set up window.__INITIAL_STATE__ with invalid target + global.window.__INITIAL_STATE__ = { + installTarget: "invalid-target", + }; + + // Mock console.error to prevent error output in tests + const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => { }); + + expect(() => { + render( + +
Test
+
+ ); + }).toThrow("Invalid installation target: invalid-target"); + + consoleSpy.mockRestore(); + }); + }); + + describe("InitialStateProvider", () => { + it("provides default values when window.__INITIAL_STATE__ is not available", () => { + + const TestComponent = () => { + const state = useInitialState(); + return ( +
+ {state.title} + {state.icon || "no-icon"} + {state.installTarget} +
+ ); + }; + + render( + + + + ); + + expect(screen.getByTestId("title")).toHaveTextContent("My App"); + expect(screen.getByTestId("icon")).toHaveTextContent("no-icon"); + expect(screen.getByTestId("target")).toHaveTextContent("linux"); + }); + + it("uses values from window.__INITIAL_STATE__ when available", () => { + + global.window.__INITIAL_STATE__ = { + title: "Custom App Title", + icon: "custom-icon.png", + installTarget: "kubernetes", + }; + + const TestComponent = () => { + const state = useInitialState(); + return ( +
+ {state.title} + {state.icon} + {state.installTarget} +
+ ); + }; + + render( + + + + ); + + expect(screen.getByTestId("title")).toHaveTextContent("Custom App Title"); + expect(screen.getByTestId("icon")).toHaveTextContent("custom-icon.png"); + expect(screen.getByTestId("target")).toHaveTextContent("kubernetes"); + }); + + it("falls back to defaults for missing properties", () => { + + global.window.__INITIAL_STATE__ = { + title: "Partial Config", + // icon and installTarget are missing + }; + + const TestComponent = () => { + const state = useInitialState(); + return ( +
+ {state.title} + {state.icon || "no-icon"} + {state.installTarget} +
+ ); + }; + + render( + + + + ); + + expect(screen.getByTestId("title")).toHaveTextContent("Partial Config"); + expect(screen.getByTestId("icon")).toHaveTextContent("no-icon"); + expect(screen.getByTestId("target")).toHaveTextContent("linux"); + }); + + it("handles empty window.__INITIAL_STATE__ object", () => { + + global.window.__INITIAL_STATE__ = {}; + + const TestComponent = () => { + const state = useInitialState(); + return ( +
+ {state.title} + {state.icon || "no-icon"} + {state.installTarget} +
+ ); + }; + + render( + + + + ); + + expect(screen.getByTestId("title")).toHaveTextContent("My App"); + expect(screen.getByTestId("icon")).toHaveTextContent("no-icon"); + expect(screen.getByTestId("target")).toHaveTextContent("linux"); + }); + + it("renders children correctly", () => { + + render( + +
Child Component
+
+ ); + + expect(screen.getByTestId("child")).toHaveTextContent("Child Component"); + }); + }); + + describe("InitialStateContext default value", () => { + it("has correct default values", () => { + const TestComponent = () => { + return ( + + {(value) => ( +
+ {value.title} + {value.installTarget} +
+ )} +
+ ); + }; + + render(); + + expect(screen.getByTestId("title")).toHaveTextContent("My App"); + expect(screen.getByTestId("target")).toHaveTextContent("linux"); + }); + }); +}); diff --git a/web/src/global.d.ts b/web/src/global.d.ts index e67927c531..65b7616a82 100644 --- a/web/src/global.d.ts +++ b/web/src/global.d.ts @@ -1,15 +1,15 @@ // src/global.d.ts export { }; +// Initial state is how the server can pass initial data to the client. +interface InitialState { + icon?: string; + title?: string; + installTarget?: string; +} + declare global { interface Window { __INITIAL_STATE__?: InitialState; } - - // Initial state is how the server can pass initial data to the client. - interface InitialState { - icon?: string; - title?: string; - installTarget?: string; - } } diff --git a/web/src/test/setup.tsx b/web/src/test/setup.tsx index 05b705eee3..ee616551c3 100644 --- a/web/src/test/setup.tsx +++ b/web/src/test/setup.tsx @@ -3,13 +3,16 @@ import { QueryClientProvider } from "@tanstack/react-query"; import { render, RenderOptions } from "@testing-library/react"; import { createMemoryRouter, RouterProvider, RouteObject } from "react-router-dom"; import { vi } from "vitest"; +import { JSX } from "react/jsx-runtime"; +import { InitialState } from "../types"; +import { InstallationTarget } from "../types/installation-target"; import { createQueryClient } from "../query-client"; import { LinuxConfigContext, LinuxConfig } from "../contexts/LinuxConfigContext"; import { KubernetesConfigContext, KubernetesConfig } from "../contexts/KubernetesConfigContext"; import { SettingsContext, Settings } from "../contexts/SettingsContext"; -import { WizardContext, WizardMode, WizardTarget } from "../contexts/WizardModeContext"; -import { BrandingContext } from "../contexts/BrandingContext"; +import { WizardContext, WizardMode } from "../contexts/WizardModeContext"; +import { InitialStateContext } from "../contexts/InitialStateContext"; import { AuthContext } from "../contexts/AuthContext"; // Mock localStorage for tests @@ -30,10 +33,7 @@ interface MockProviderProps { children: React.ReactNode; queryClient: ReturnType; contexts: { - brandingContext: { - title: string; - icon?: string; - }; + initialStateContext: InitialState linuxConfigContext: { config: LinuxConfig; updateConfig: (newConfig: Partial) => void; @@ -49,7 +49,7 @@ interface MockProviderProps { updateSettings: (newSettings: Partial) => void; }; wizardModeContext: { - target: WizardTarget; + target: InstallationTarget; mode: WizardMode; text: { title: string; @@ -87,19 +87,19 @@ const MockProvider = ({ children, queryClient, contexts }: MockProviderProps) => }, [contexts.authContext.token]); return ( - - - - - - + + + + + + {children} - - - - - - + + + + + + ); }; @@ -111,7 +111,7 @@ interface RenderWithProvidersOptions extends RenderOptions { routePath?: string; authenticated?: boolean; authToken?: string; - target?: WizardTarget; + target?: InstallationTarget; }; wrapper?: React.ComponentType<{ children: React.ReactNode }>; } @@ -121,7 +121,7 @@ export const renderWithProviders = ( options: RenderWithProvidersOptions = {}, ) => { const defaultContextValues: MockProviderProps["contexts"] = { - brandingContext: { title: "My App" }, + initialStateContext: { title: "My App", installTarget: options.wrapperProps?.target || "linux" }, linuxConfigContext: { config: { adminConsolePort: 8800, @@ -174,7 +174,7 @@ export const renderWithProviders = ( }; const mergedContextValues: MockProviderProps["contexts"] = { - brandingContext: { ...defaultContextValues.brandingContext, ...options.wrapperProps?.contextValues?.brandingContext }, + initialStateContext: { ...defaultContextValues.initialStateContext, ...options.wrapperProps?.contextValues?.initialStateContext }, linuxConfigContext: { ...defaultContextValues.linuxConfigContext, ...options.wrapperProps?.contextValues?.linuxConfigContext }, kubernetesConfigContext: { ...defaultContextValues.kubernetesConfigContext, ...options.wrapperProps?.contextValues?.kubernetesConfigContext }, settingsContext: { ...defaultContextValues.settingsContext, ...options.wrapperProps?.contextValues?.settingsContext }, diff --git a/web/src/types/index.ts b/web/src/types/index.ts index 8262d1cc43..79d3da7a46 100644 --- a/web/src/types/index.ts +++ b/web/src/types/index.ts @@ -1,3 +1,11 @@ +import { InstallationTarget } from './installation-target'; + +export interface InitialState { + title: string; + icon?: string; + installTarget: InstallationTarget; +} + export interface InfraStatusResponse { components: InfraComponent[]; status: InfraStatus; diff --git a/web/src/types/installation-target.ts b/web/src/types/installation-target.ts new file mode 100644 index 0000000000..e8acab3a23 --- /dev/null +++ b/web/src/types/installation-target.ts @@ -0,0 +1,7 @@ +export const installationTargets = ['linux', 'kubernetes'] as const; + +export type InstallationTarget = typeof installationTargets[number]; + +export function isInstallationTarget(value: string): value is InstallationTarget { + return (installationTargets as readonly string[]).includes(value); +}