diff --git a/src/app/cell.tsx b/src/app/cell.tsx index 3a68019..22294a9 100644 --- a/src/app/cell.tsx +++ b/src/app/cell.tsx @@ -1,15 +1,23 @@ import {MazeCell} from "./model/maze.ts"; import Coordinates from "./model/coordinates.ts"; -import {actionClickedCell} from "./state/action.ts"; +import {Action, actionClickedCell} from "./state/action.ts"; import styles from "./cell.module.css"; import "./cell.css"; +import {State} from "./state/state.ts"; +import {ActionDispatch} from "react"; function isMarked(x: number, y: number, marked: Coordinates[]): boolean { return !!marked.find(e => e.x === x && e.y === y); } -export default function Cell({x, y, state, dispatch}) { - const cell: MazeCell = state.maze.grid[y][x]; +export default function Cell({x, y, state, dispatch}: + { + x: number, + y: number, + state: State, + dispatch: ActionDispatch<[Action]> + }) { + const cell: MazeCell = state.maze!.grid[y][x]; let classes = " r" + y + " c" + x; if (cell.top) classes += " top"; if (cell.right) classes += " right"; @@ -26,7 +34,7 @@ export default function Cell({x, y, state, dispatch}) { dispatch(actionClickedCell(x, y)); } }} - onClick={(e) => { + onClick={() => { dispatch(actionClickedCell(x, y)); }}> diff --git a/src/app/input-form.tsx b/src/app/input-form.tsx index e2d30ef..af11612 100644 --- a/src/app/input-form.tsx +++ b/src/app/input-form.tsx @@ -1,16 +1,20 @@ -import {useState} from 'react'; -import ValidatingInputNumberField from "./validating-input-number-field.tsx"; -import {actionLoadedMaze, actionLoadingFailed, actionStartedLoading} from "./state/action.ts"; +import {ActionDispatch, FormEvent, useState} from 'react'; +import ValidatingInputNumberField, {ValidatorFunction} from "./validating-input-number-field.tsx"; +import {Action, actionLoadedMaze, actionLoadingFailed, actionStartedLoading} from "./state/action.ts"; import styles from "./input-form.module.css"; import "./input-form.css"; +import {State} from "@/app/state/state.ts"; -export default function InputForm({state, dispatch}) { +export default function InputForm({state, dispatch}: { + state: State, + dispatch: ActionDispatch<[Action]> +}) { const [width, setWidth] = useState(10); const [height, setHeight] = useState(10); - const [id, setId] = useState(null as number); + const [id, setId] = useState(); const [algorithm, setAlgorithm] = useState('wilson'); - const handleSubmit = (e) => { + const handleSubmit = (e: FormEvent) => { e.preventDefault(); dispatch(actionStartedLoading()); const url = `https://manuel.friedli.info/labyrinth/create/json?w=${width}&h=${height}&id=${id || ''}&algorithm=${algorithm}`; @@ -25,38 +29,42 @@ export default function InputForm({state, dispatch}) { dispatch(actionLoadingFailed(reason)); }); }; - const validateWidthHeightInput = value => { - if (isNaN(value) || "" === value || (Math.floor(value) !== Number(value))) { + const validateWidthHeightInput: ValidatorFunction = value => { + const numberValue = Number(value); + if (isNaN(numberValue) || "" === value || (Math.floor(numberValue) !== numberValue)) { return { valid: false, - message: "Must be an integer greater than 1.", - value + message: "Must be an integer greater than 1." }; } - if (value < 1) { + if (numberValue < 1) { return { valid: false, - message: "Must be greater than 1.", - value + message: "Must be greater than 1." }; } return { valid: true, - value + value: numberValue }; }; - const validateIdInput = value => { + const validateIdInput: ValidatorFunction = value => { + if ("" === value) { + return { + valid: true + }; + } + const numberValue = Number(value); // FIXME doesn't handle strings with characters correctly (e.g. "asdf" yields an empty value, due to "type=number"). - if (isNaN(value) || ("" !== value && ((Math.floor(value) !== Number(value))))) { + if (isNaN(numberValue) || Math.floor(numberValue) !== numberValue) { return { valid: false, - message: "Must be empty or an integer", - value + message: "Must be empty or an integer" }; } return { valid: true, - value + value: numberValue } }; return ( diff --git a/src/app/maze.tsx b/src/app/maze.tsx index 5f55d8b..bab0423 100644 --- a/src/app/maze.tsx +++ b/src/app/maze.tsx @@ -1,14 +1,21 @@ import Cell from "./cell.tsx"; import styles from "./maze.module.css"; +import {State} from "@/app/state/state.ts"; +import {ActionDispatch, JSX} from "react"; +import {Action} from "@/app/state/action.ts"; -export default function Maze({state, dispatch}) { +export default function Maze({state, dispatch}: + { + state: State, + dispatch: ActionDispatch<[Action]> + }) { if (!state.maze) { return
No valid maze.
} - let maze: JSX.Element[] = []; + const maze: JSX.Element[] = []; for (let y = 0; y < state.maze.height; y++) { - let row: JSX.Element[] = []; + const row: JSX.Element[] = []; for (let x = 0; x < state.maze.width; x++) { row.push() } diff --git a/src/app/message-banner.tsx b/src/app/message-banner.tsx index 92fdd22..a33eaf8 100644 --- a/src/app/message-banner.tsx +++ b/src/app/message-banner.tsx @@ -1,7 +1,13 @@ -import {actionClosedMessageBanner} from "./state/action.ts"; +import {Action, actionClosedMessageBanner} from "./state/action.ts"; import styles from "./message-banner.module.css"; +import {State} from "@/app/state/state.ts"; +import {ActionDispatch} from "react"; -export default function MessageBanner({state, dispatch}) { +export default function MessageBanner({state, dispatch}: + { + state: State; + dispatch: ActionDispatch<[Action]> + }) { function handleClose() { dispatch(actionClosedMessageBanner()); } diff --git a/src/app/page.tsx b/src/app/page.tsx index fab1311..a03b3a8 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -22,8 +22,8 @@ export default function Home() { dispatch={dispatch}/> {hasValidMaze && <> -

The Maze ({state.maze.width}x{state.maze.height}, Algorithm: {state.maze.algorithm}, - ID: {state.maze.id})

+

The Maze ({state.maze!.width}x{state.maze!.height}, Algorithm: {state.maze!.algorithm}, + ID: {state.maze!.id})

{ dispatch(actionToggledShowSolution(e.target.checked)); diff --git a/src/app/state/action.ts b/src/app/state/action.ts index 2c08b17..0d41231 100644 --- a/src/app/state/action.ts +++ b/src/app/state/action.ts @@ -3,7 +3,7 @@ import Maze from "../model/maze.ts"; export interface Action { type: string, - [key: string]: any + [key: string]: boolean | number | string | object | null | undefined; } export const ID_ACTION_STARTED_LOADING = 'started_loading'; diff --git a/src/app/state/reducer.ts b/src/app/state/reducer.ts index 04257a0..da97b26 100644 --- a/src/app/state/reducer.ts +++ b/src/app/state/reducer.ts @@ -9,6 +9,7 @@ import { ID_ACTION_STARTED_LOADING, ID_ACTION_TOGGLED_SHOW_SOLUTION } from "./action.ts"; +import Maze from "@/app/model/maze.ts"; export default function reduce(state: State, action: Action): State { switch (action.type) { @@ -24,7 +25,7 @@ export default function reduce(state: State, action: Action): State { return { ...state, loading: false, - maze: action.maze, + maze: action.maze as Maze, userPath: [] } } @@ -38,7 +39,7 @@ export default function reduce(state: State, action: Action): State { case ID_ACTION_TOGGLED_SHOW_SOLUTION: { return { ...state, - showSolution: action.value + showSolution: action.value as boolean } } case ID_ACTION_CLOSED_MESSAGE_BANNER: { @@ -49,7 +50,7 @@ export default function reduce(state: State, action: Action): State { } case ID_ACTION_CLICKED_CELL: { // There's so much logic involved, externalize that into its own file. - return handleUserClicked(state, action.x, action.y); + return handleUserClicked(state, action.x as number, action.y as number); } default: { throw new Error(`Unknown action: ${action.type}`); diff --git a/src/app/state/userpathhandler.ts b/src/app/state/userpathhandler.ts index 56de445..e6c5f8d 100644 --- a/src/app/state/userpathhandler.ts +++ b/src/app/state/userpathhandler.ts @@ -4,7 +4,7 @@ import {MazeCell} from "../model/maze.ts"; export default function handleUserClicked(state: State, x: number, y: number): State { if (isClickAllowed(x, y, state)) { - let maze = state.maze!; + const maze = state.maze!; // Okay, we clicked a cell that's adjacent to the end of the userpath (or which IS the end of the userpath) // and that's not blocked by a wall. Now let's see. if (-1 === state.userPath.findIndex(step => step.x === x && step.y === y)) { diff --git a/src/app/validating-input-number-field.tsx b/src/app/validating-input-number-field.tsx index 08842b1..4ef6a66 100644 --- a/src/app/validating-input-number-field.tsx +++ b/src/app/validating-input-number-field.tsx @@ -1,28 +1,39 @@ -import React, {useState} from 'react'; +import React, {ChangeEventHandler, useState} from 'react'; export default function ValidatingInputNumberField({ id, label, value = 0, - constraints = {}, + constraints = undefined, validatorFn = (value) => { - return {valid: true, value}; + return {valid: true, value: Number(value)}; }, disabled = false, - onChange = _ => { + onChange = () => { } + }: + { + id: string; + label: string; + value?: number; + constraints?: { min?: number; max?: number; }; + validatorFn: ValidatorFunction; + disabled: boolean; + onChange: (v: number) => void; }) { - const [error, setError] = useState(null); + const [error, setError] = useState(); - const handleValueChange = (e) => { + const handleValueChange: ChangeEventHandler = (e) => { const value = e.target.value; const validation = validatorFn(value); if (!validation.valid) { setError(validation.message); } else { - setError(null); + setError(undefined); + } + if (validation.value) { + onChange(validation.value); } - onChange(validation.value); }; return ( <> @@ -31,11 +42,19 @@ export default function ValidatingInputNumberField({ type={"number"} onChange={handleValueChange} value={value || ""} - min={constraints.min || null} - max={constraints.max || null} + min={constraints?.min} + max={constraints?.max} disabled={disabled} /> {error} ); } + +export interface Validation { + valid: boolean; + message?: string; + value?: T; +} + +export type ValidatorFunction = (v: I) => Validation;