diff --git a/README.md b/README.md index b0f1cea..13ecf38 100644 --- a/README.md +++ b/README.md @@ -2,42 +2,61 @@ Hello there! -Your task is to implement a new Stepper component +### Task Overview + +Implemented a new Stepper component based on [these specifications in Figma](https://www.figma.com/design/gvf4xjR6f9rvaj94BPFFyZ/Frontend-Coding-Challenge?node-id=0-1&t=tHGuIxWbpBVVsH5B-1). -Make sure your component behaves as expected by -creating tests for it. ## File structure -The relevant files are these + ```text __tests__/ components/ stepper/ stepper.test.tsx - -> Implement your tests here + -> Contains tests for the Stepper component, verifying that it renders correctly and displays the steps. + stepPage.test.tsx + -> Contains tests for the StepPage component, verifying that it renders the correct content based on the route, calls the `setCurrentStep` function with the correct index, and handles invalid routes. + components/ stepper/ - stepper.tsx + stepper.tsx -> The stepper component steps.ts - -> You might want to use this to - configure the steps + -> Configuring the steps + stepMapping.tsx + -> step mapping for each page + stepPage.ts + -> For creating dynamic routes hooks/ useStepper.ts -> Custom hook that handles navigation + +context/ + stepperContext.tsx + -> Contains React context providers for managing application state- `StepperContext` + pages/ index.tsx -> The main page (localhost:3000) + termin.tsx, fahrzeug.tsx, kontakt.tsx: + -> Page components that use the StepPage component. ``` +## Architecture Diagram + +![Project Architecture](public/architectureDiagram.png) + ## Setting up everything Install the dependencies + ```bash npm install ``` Start the dev server to get a preview + ```bash npm run dev ``` @@ -45,28 +64,19 @@ npm run dev Check out the page on [localhost:3000](http://localhost:3000) To run the tests + ```bash npm run test ``` -## Getting started - -Get started by having a look at the relevant files -and then start implementing the Stepper component in -`/components/stepper/stepper.tsx`. - -## Things to consider - -Please use **Tailwind CSS** to style the component. -Please **do not install any additional packages**. - -Make sure the component - -- aligns **exactly** with the Figma spec (we already -implemented the page layout for you, -you just have to worry about the stepper component) -- is **reusable** -- works with **different sets of steps** -- works well on **all breakpoints** +## Features & Functionality -Good luck! :) \ No newline at end of file +- Stepper Component implementation ✅ +- Steps configured ✅ +- useStepper hook to encapsulate the stepper logic and manage navigation of steps and routing ✅ +- Responsiveness ✅ +- StepperContext to manage the stepper state (steps, current step) and functions (handleNextStep, setCurrentStep) ✅ +- Dynamic step management ✅ +- Basic error handling to display an "Invalid Step" message if the route does not match a valid step. ✅ +- StepPage is a reusable component that renders the core layout of each step page ✅ +- Tests for Stepper component and StepPage ✅ diff --git a/__tests__/components/stepper/stepPage.test.tsx b/__tests__/components/stepper/stepPage.test.tsx new file mode 100644 index 0000000..6746973 --- /dev/null +++ b/__tests__/components/stepper/stepPage.test.tsx @@ -0,0 +1,105 @@ +import React from "react"; +import { render, screen } from "@testing-library/react"; +import "@testing-library/jest-dom"; +import StepPage from "../../../components/stepper/stepPage"; +import { useStepperContext } from "../../../context/stepperContext"; +import { useRouter } from "next/router"; + +jest.mock("next/router", () => ({ + useRouter: jest.fn(), +})); + +jest.mock("../../../context/stepperContext", () => ({ + useStepperContext: jest.fn(), +})); + + +jest.mock("../../../components/stepper/stepMapping", () => { + const mockStepMapping = { step1: 1, step2: 2, termin: 1 }; + return { + stepMapping: mockStepMapping, + }; +}); + +describe("StepPage", () => { + const mockSteps = [ + { title: "Step 1", isCompleted: false, navigateTo: "/step1" }, + { title: "Step 2", isCompleted: false, navigateTo: "/step2" }, + ]; + + const mockUseStepperContext = { + steps: mockSteps, + currentStep: 0, + handleNextStep: jest.fn(), + setCurrentStep: jest.fn(), + }; + + beforeEach(() => { + (useStepperContext as jest.Mock).mockReturnValue(mockUseStepperContext); + (useRouter as jest.Mock).mockReturnValue({ + pathname: "/step1", + query: {}, + push: jest.fn(), + events: { + emit: jest.fn(), + on: jest.fn(), + off: jest.fn(), + }, + beforePopState: jest.fn(() => null), + prefetch: jest.fn(() => Promise.resolve()), + }); + }); + + it("renders the Stepper component", () => { + (useRouter as jest.Mock).mockReturnValue({ + pathname: "/step1", + query: {}, + push: jest.fn(), + events: { + emit: jest.fn(), + on: jest.fn(), + off: jest.fn(), + }, + beforePopState: jest.fn(() => null), + prefetch: jest.fn(() => Promise.resolve()), + }); + render(); + expect(screen.getByText("Step 1")).toBeInTheDocument(); + }); + + it("calls setCurrentStep with the correct step index based on pathname", () => { + (useRouter as jest.Mock).mockReturnValue({ + pathname: "/termin", + query: {}, + push: jest.fn(), + events: { + emit: jest.fn(), + on: jest.fn(), + off: jest.fn(), + }, + beforePopState: jest.fn(() => null), + prefetch: jest.fn(() => Promise.resolve()), + }); + + render(); + expect(mockUseStepperContext.setCurrentStep).toHaveBeenCalledWith(1); + }); + + it("renders 'Invalid Step' if the pathname does not match any step", () => { + (useRouter as jest.Mock).mockReturnValue({ + pathname: "/unknown", + query: {}, + push: jest.fn(), + events: { + emit: jest.fn(), + on: jest.fn(), + off: jest.fn(), + }, + beforePopState: jest.fn(() => null), + prefetch: jest.fn(() => Promise.resolve()), + }); + + const { container } = render(); + expect(container).toHaveTextContent("Invalid Step"); + }); +}); diff --git a/__tests__/components/stepper/stepper.test.tsx b/__tests__/components/stepper/stepper.test.tsx index 9a9c705..69096ba 100644 --- a/__tests__/components/stepper/stepper.test.tsx +++ b/__tests__/components/stepper/stepper.test.tsx @@ -1,6 +1,61 @@ -// TODO: Implement your tests here +import React from "react"; +import { render, screen } from "@testing-library/react"; +import "@testing-library/jest-dom"; +import Stepper from "../../../components/stepper/stepper"; +import { steps } from "../../../components/stepper/steps"; + describe("Stepper", () => { - it("TODO", () => { - expect(true).toBe(false); - }); + it("renders all steps", () => { + render(); + + steps.forEach((step, index) => { + expect(screen.getByText(step.title)).toBeInTheDocument(); + expect(screen.getByText(String(index + 1))).toBeInTheDocument(); + }); + }); + + it("highlights current and previous steps", () => { + const current = 2; + const stepsWithCompleted = steps.map((s, i) => ({ ...s, isCompleted: i <= current })); + + render(); + + for (let i = 0; i <= current; i++) { + const stepCircle = screen.getByText(String(i + 1)); + expect(stepCircle).toHaveClass("bg-blue-500"); + } + }); + + it("renders correct number of connection lines", () => { + render(); + + const connectionLines = screen.getAllByTestId("connection-line"); + expect(connectionLines).toHaveLength(steps.length - 1); + }); + + it("applies responsive classes", () => { + render(); + + const stepCircle = screen.getByText("1"); + expect(stepCircle).toHaveClass("w-[30px]"); + expect(stepCircle).toHaveClass("h-[30px]"); + expect(stepCircle).toHaveClass("md:w-10"); + expect(stepCircle).toHaveClass("md:h-10"); + + const stepTitle = screen.getByText(steps[0].title); + expect(stepTitle).toHaveClass("text-[12px]"); + expect(stepTitle).toHaveClass("lg:text-[16px]"); + }); + + it("renders correctly with last step as current", () => { + const lastIndex = steps.length - 1; + const stepsAllCompleted = steps.map((s) => ({ ...s, isCompleted: true })); + + render(); + + steps.forEach((_, index) => { + const stepCircle = screen.getByText(String(index + 1)); + expect(stepCircle).toHaveClass("bg-blue-500"); + }); + }); }); diff --git a/components/stepper/stepMapping.ts b/components/stepper/stepMapping.ts new file mode 100644 index 0000000..ba5e431 --- /dev/null +++ b/components/stepper/stepMapping.ts @@ -0,0 +1,23 @@ +import { steps, Step } from "./steps"; + +interface StepMapping { + [key: string]: number; +} + +export const stepMapping: StepMapping = steps.reduce( + (accStepMapping: StepMapping, step: Step, stepIndex: number) => { + const pageName: string | null = step.navigateTo + ? step.navigateTo.replace("/", "") + : null; + // console.log( + // `Mapping step "${step.title}" to page "${pageName}" with index ${index}` + // ); + + if (pageName) { + accStepMapping[pageName] = stepIndex + 1; + } + + return accStepMapping; + }, + {} +); diff --git a/components/stepper/stepPage.tsx b/components/stepper/stepPage.tsx new file mode 100644 index 0000000..ea9c8cb --- /dev/null +++ b/components/stepper/stepPage.tsx @@ -0,0 +1,47 @@ +import { useEffect } from "react"; +import Header from "../../components/repareo/header"; +import MainWrapper from "../../components/repareo/mainWrapper"; +import StepperWrapper from "../../components/repareo/stepperWrapper"; +import Stepper from "../../components/stepper/stepper"; +import ButtonWrapper from "../../components/repareo/buttonWrapper"; +import Button from "../../components/repareo/button"; +import { useStepperContext } from "../../context/stepperContext"; +import { useRouter } from "next/router"; +import { stepMapping } from "./stepMapping"; + +interface StepPageProps {} + +export default function StepPage({}: StepPageProps) { + const { steps, currentStep, handleNextStep, setCurrentStep } = + useStepperContext(); + + const router = useRouter(); + const { pathname } = router; + const pageName = pathname.split("/").pop() || ""; + const stepIndex = stepMapping[pageName]; + useEffect(() => { + if (stepIndex) { + setCurrentStep(stepIndex); + } + }, [setCurrentStep, stepIndex]); + + if (!stepIndex) { + return

Invalid Step

; + } + + return ( + <> +
+ + + + + + {currentStep < steps.length - 1 && ( + + )} + + + + ); +} diff --git a/components/stepper/stepper.tsx b/components/stepper/stepper.tsx index fe3602e..6314a03 100644 --- a/components/stepper/stepper.tsx +++ b/components/stepper/stepper.tsx @@ -1,14 +1,42 @@ -interface StepperProps {} +import { Step } from "./steps"; +interface StepperProps { + steps: Step[]; + currentStep: number; +} + +export default function Stepper({ steps, currentStep }: StepperProps) { + return ( +
+
+ {steps.map((step, index) => ( +
+
+ {index + 1} +
+ {index < steps.length - 1 && ( +
+ )} -export default function Stepper() { - /*TODO: Replace this with the actual Stepper implementation*/ - return ( -
- {""} -
- ); +
+ {step.title} +
+
+ ))} +
+
+ ); } diff --git a/components/stepper/steps.ts b/components/stepper/steps.ts index d840d60..a330349 100644 --- a/components/stepper/steps.ts +++ b/components/stepper/steps.ts @@ -1,5 +1,12 @@ -interface Step { - title: string; +export interface Step { + title: string; + isCompleted: boolean; + navigateTo: string | null; } -export const steps: Step[] = []; +export const steps: Step[] = [ + { title: "Service", isCompleted: false, navigateTo: "/termin" }, + { title: "Termin", isCompleted: false, navigateTo: "/fahrzeug" }, + { title: "Fahrzeug", isCompleted: false, navigateTo: "/kontakt" }, + { title: "Kontakt", isCompleted: false, navigateTo: null }, +]; diff --git a/context/stepperContext.tsx b/context/stepperContext.tsx new file mode 100644 index 0000000..b7fec49 --- /dev/null +++ b/context/stepperContext.tsx @@ -0,0 +1,44 @@ +import React, { + createContext, + useContext, + ReactNode, + useState, + useCallback, +} from "react"; +import useStepper from "../hooks/useStepper"; +import { Step } from "../components/stepper/steps"; + +interface StepperContextType { + steps: Step[]; + currentStep: number; + handleNextStep: () => void; + setCurrentStep: (step: number) => void; +} + +const StepperContext = createContext(undefined); + +export function StepperProvider({ children }: { children: ReactNode }) { + const [contextCurrentStep, setContextCurrentStep] = useState(0); + + const onStepChange = useCallback((step: number) => { + setContextCurrentStep(step); + }, []); + + const stepperHook = useStepper(onStepChange); + + return ( + + {children} + + ); +} + +export function useStepperContext() { + const context = useContext(StepperContext); + if (context === undefined) { + throw new Error("useStepperContext must be used within a StepperProvider"); + } + return context; +} diff --git a/hooks/useStepper.ts b/hooks/useStepper.ts index 588deeb..aaa8ede 100644 --- a/hooks/useStepper.ts +++ b/hooks/useStepper.ts @@ -1,11 +1,44 @@ -import { useState } from "react"; - -export default function useStepper() { - const [currentStep, setCurrentStep] = useState(0); - function handleNextStep() { - setCurrentStep((prev) => { - return prev + 1; - }); - } - return { currentStep, handleNextStep }; +import { useState, useCallback } from "react"; +import { steps as initialSteps, Step } from "../components/stepper/steps"; +import { useRouter } from "next/router"; + +export default function useStepper(onStepChange?: (step: number) => void) { + const router = useRouter(); + const [stepsState, setStepsState] = useState(() => + initialSteps.map((s) => ({ ...s })) + ); + const [currentStep, setCurrentStep] = useState(0); + + const updateStepCompletion = useCallback((stepIndex: number) => { + setStepsState((prevSteps) => + prevSteps.map((step, index) => + index < stepIndex ? { ...step, isCompleted: true } : step + ) + ); + }, []); + + const setCurrentStepAndNotify = useCallback((newStep: number) => { + setCurrentStep(newStep); + updateStepCompletion(newStep); + if (onStepChange) { + onStepChange(newStep); + } + }, [onStepChange, updateStepCompletion]); + + const handleNextStep = useCallback(() => { + const nextStep = Math.min(currentStep + 1, stepsState.length - 1); + updateStepCompletion(nextStep); + setCurrentStepAndNotify(nextStep); + const navigateTo = stepsState[currentStep]?.navigateTo ?? ""; + if (navigateTo) { + router.push(navigateTo); + } + }, [currentStep, stepsState, router, setCurrentStepAndNotify, updateStepCompletion]); + + return { + steps: stepsState, + currentStep, + handleNextStep, + setCurrentStep: setCurrentStepAndNotify + }; } diff --git a/pages/_app.tsx b/pages/_app.tsx index 5b48a79..d802510 100644 --- a/pages/_app.tsx +++ b/pages/_app.tsx @@ -1,8 +1,11 @@ import "../styles/globals.css"; import type { AppProps } from "next/app"; +import { StepperProvider } from "../context/stepperContext"; function MyApp({ Component, pageProps }: AppProps) { - return ; + return + + ; } export default MyApp; diff --git a/pages/fahrzeug.tsx b/pages/fahrzeug.tsx new file mode 100644 index 0000000..3045565 --- /dev/null +++ b/pages/fahrzeug.tsx @@ -0,0 +1,5 @@ +import StepPage from "../components/stepper/stepPage"; + +export default function Termin() { + return ; +} \ No newline at end of file diff --git a/pages/index.tsx b/pages/index.tsx index f1ad62d..9f58f61 100644 --- a/pages/index.tsx +++ b/pages/index.tsx @@ -7,19 +7,19 @@ import Stepper from "../components/stepper/stepper"; import useStepper from "../hooks/useStepper"; export default function Home() { - const { currentStep, handleNextStep } = useStepper(); - return ( - <> -
- - - {/*TODO: Make sure the Stepper handles clicks on the button*/} - - - - - - - - ); + const { steps, currentStep, handleNextStep } = useStepper(); + return ( + <> +
+ + + {/*TODO: Make sure the Stepper handles clicks on the button*/} + + + + + + + + ); } diff --git a/pages/kontakt.tsx b/pages/kontakt.tsx new file mode 100644 index 0000000..3045565 --- /dev/null +++ b/pages/kontakt.tsx @@ -0,0 +1,5 @@ +import StepPage from "../components/stepper/stepPage"; + +export default function Termin() { + return ; +} \ No newline at end of file diff --git a/pages/termin.tsx b/pages/termin.tsx new file mode 100644 index 0000000..3045565 --- /dev/null +++ b/pages/termin.tsx @@ -0,0 +1,5 @@ +import StepPage from "../components/stepper/stepPage"; + +export default function Termin() { + return ; +} \ No newline at end of file diff --git a/public/architectureDiagram.png b/public/architectureDiagram.png new file mode 100644 index 0000000..b1f4d4b Binary files /dev/null and b/public/architectureDiagram.png differ