Repository: online-go/online-go.com
Pull request #3041: Challenge Modal State Rework
Author: login: merowin
Created: 2025-04-05T03:19:34Z | Merged: 2025-04-09T11:26:07Z

Update the state management of `ChallengeModal` (part 1), + general improvements

## Proposed Changes

  - Add explicit type for the state (constructed s.t. it compiles with minimal changes)
  - replace usages of `upstate` by `setState` for update bindings for: `bot_id, demo_name, game_name, handicap`
  - add interface `ChallengeModalInput` to reduce code duplication
  - convert `string` to `int` before handicap input values get into the state
  - remove unused module exports
  - fix input type for function `updateSystem` in `TimeControlUpdates.ts`
  - sanitize handicap when coming from local storage (currently it can have type number or string)


Diff:
diff --git a/src/components/ChallengeModal/ChallengeModal.api.tsx b/src/components/ChallengeModal/ChallengeModal.api.tsx
index 493d6052cf..2322567760 100644
--- a/src/components/ChallengeModal/ChallengeModal.api.tsx
+++ b/src/components/ChallengeModal/ChallengeModal.api.tsx
@@ -22,7 +22,7 @@ import {
     ChallengeModes,
 } from "src/components/ChallengeModal/ChallengeModal.types";
 import { openModal } from "@/components/Modal";
-import { ChallengeModal, isStandardBoardSize } from "@/components/ChallengeModal";
+import { ChallengeModal, isStandardBoardSize } from "@/components/ChallengeModal/ChallengeModal";
 import { GobanEngineConfig, GobanEnginePlayerEntry, GobanRenderer } from "goban";
 import { dup } from "@/lib/misc";
 
diff --git a/src/components/ChallengeModal/ChallengeModal.tsx b/src/components/ChallengeModal/ChallengeModal.tsx
index bfb8627363..fa5187ca59 100644
--- a/src/components/ChallengeModal/ChallengeModal.tsx
+++ b/src/components/ChallengeModal/ChallengeModal.tsx
@@ -52,12 +52,21 @@ import Select from "react-select";
 import {
     rejectionDetailsToMessage,
     challenge_text_description,
+    sanitizeChallengeDetails,
+    getPreferredSettings,
 } from "@/components/ChallengeModal/ChallengeModal.utils";
 import {
     ChallengeDetails,
+    ChallengeModalChallengeSettings,
+    ChallengeModalConf,
+    ChallengeModalDemoSettings,
+    ChallengeModalGameSettings,
+    ChallengeModalInput,
     ChallengeModalProperties,
+    ChallengeModalState,
     PreferredSettingOption,
     RejectionDetails,
+    UpdateFn,
 } from "@/components/ChallengeModal/ChallengeModal.types";
 
 /* Constants  */
@@ -81,7 +90,7 @@ const standard_board_sizes: { [k: string]: string | undefined } = {
     "5x13": "5x13",
 };
 
-export class ChallengeModal extends Modal<{}, ChallengeModalProperties, any> {
+export class ChallengeModal extends Modal<{}, ChallengeModalProperties, ChallengeModalState> {
     constructor(props: ChallengeModalProperties) {
         super(props);
     }
@@ -91,53 +100,38 @@ export class ChallengeModal extends Modal<{}, ChallengeModalProperties, any> {
     }
 }
 
-export class ChallengeModalBody extends React.Component<
-    ChallengeModalProperties & {
-        modal: {
-            close?: () => void;
-            on: (event: "open" | "close", callback: () => void) => void;
-            off: (event: "open" | "close", callback: () => void) => void;
-        };
-    },
-    any
-> {
+export class ChallengeModalBody extends React.Component<ChallengeModalInput, ChallengeModalState> {
     ref: React.RefObject<HTMLDivElement | null> = React.createRef();
 
-    constructor(
-        props: ChallengeModalProperties & {
-            modal: {
-                close?: () => void;
-                on: (event: "open" | "close", callback: () => void) => void;
-                off: (event: "open" | "close", callback: () => void) => void;
-            };
-        },
-    ) {
+    constructor(props: ChallengeModalInput) {
         super(props);
 
         const speed = data.get("challenge.speed", "live");
 
-        const challenge: ChallengeDetails = data.get(`challenge.challenge.${speed}`, {
-            initialized: false,
-            min_ranking: 5,
-            max_ranking: 36,
-            challenger_color: "automatic",
-            rengo_auto_start: 0,
-            game: {
-                name: "",
-                rules: "japanese",
-                ranked: true,
-                width: 19,
-                height: 19,
-                handicap: -1,
-                komi_auto: "automatic",
-                komi: 5.5,
-                disable_analysis: false,
-                initial_state: null,
-                private: false,
-                rengo: false,
-                rengo_casual_mode: true,
-            },
-        });
+        const challenge: ChallengeDetails = sanitizeChallengeDetails(
+            data.get(`challenge.challenge.${speed}`, {
+                initialized: false,
+                min_ranking: 5,
+                max_ranking: 36,
+                challenger_color: "automatic",
+                rengo_auto_start: 0,
+                game: {
+                    name: "",
+                    rules: "japanese",
+                    ranked: true,
+                    width: 19,
+                    height: 19,
+                    handicap: -1,
+                    komi_auto: "automatic",
+                    komi: 5.5,
+                    disable_analysis: false,
+                    initial_state: null,
+                    private: false,
+                    rengo: false,
+                    rengo_casual_mode: true,
+                },
+            }),
+        );
 
         const demo = data.get("demo.settings", {
             name: "",
@@ -205,7 +199,7 @@ export class ChallengeModalBody extends React.Component<
                 ? this.props.playersList.length - 1
                 : 0,
 
-            preferred_settings: data.get("preferred-game-settings", []),
+            preferred_settings: getPreferredSettings(),
             view_mode: goban_view_mode(),
             hide_preferred_settings_on_portrait: true,
             input_value_warning: false,
@@ -289,7 +283,7 @@ export class ChallengeModalBody extends React.Component<
         if (!preferred_settings) {
             return;
         }
-        this.setState({ preferred_settings: preferred_settings });
+        this.setState({ preferred_settings: preferred_settings.map(sanitizeChallengeDetails) });
     };
 
     syncBoardSize(value: string) {
@@ -371,7 +365,7 @@ export class ChallengeModalBody extends React.Component<
     }
 
     addToPreferredSettings = () => {
-        const preferred_settings = data.get("preferred-game-settings", []);
+        const preferred_settings = getPreferredSettings();
         const challenge = JSON.parse(JSON.stringify(this.getChallenge()));
         preferred_settings.push(challenge);
         data.set(
@@ -385,7 +379,7 @@ export class ChallengeModalBody extends React.Component<
     };
 
     deletePreferredSetting = (index: number) => {
-        const preferred_settings = data.get("preferred-game-settings", []);
+        const preferred_settings = getPreferredSettings();
         preferred_settings.splice(index, 1);
         data.set(
             "preferred-game-settings",
@@ -395,7 +389,7 @@ export class ChallengeModalBody extends React.Component<
     };
 
     usePreferredSetting = (index: number) => {
-        const preferred_settings = data.get("preferred-game-settings", []);
+        const preferred_settings = getPreferredSettings();
         const setting: ChallengeDetails = JSON.parse(JSON.stringify(preferred_settings[index]));
         if (this.props.mode !== "open") {
             setting.rengo_auto_start = 0;
@@ -752,10 +746,26 @@ export class ChallengeModalBody extends React.Component<
     };
 
     /* update bindings  */
-    update_conf_bot_id = (ev: React.ChangeEvent<HTMLSelectElement>) =>
-        this.upstate("conf.bot_id", parseInt(ev.target.value));
-    update_challenge_game_name = (ev: React.ChangeEvent<HTMLInputElement>) =>
-        this.upstate(this.gameStateName("name"), ev);
+
+    /* nested fn updates */
+    update_conf = (update_fn: UpdateFn<ChallengeModalConf>): void =>
+        this.setState((prev) => ({ conf: update_fn(prev.conf) }));
+    update_challenge_settings = (update_fn: UpdateFn<ChallengeModalChallengeSettings>): void =>
+        this.setState((prev) => ({ challenge: update_fn(prev.challenge) }));
+    update_demo_settings = (update_fn: UpdateFn<ChallengeModalDemoSettings>): void =>
+        this.setState((prev) => ({ demo: update_fn(prev.demo) }));
+    update_game_settings = (update_fn: UpdateFn<ChallengeModalGameSettings>): void =>
+        this.update_challenge_settings((prev) => ({ ...prev, game: update_fn(prev.game) }));
+
+    /* direct fn updates */
+    update_bot_id = (id: number) => this.update_conf((prev) => ({ ...prev, bot_id: id }));
+    update_demo_name = (name: string): void =>
+        this.update_demo_settings((prev) => ({ ...prev, name: name }));
+    update_game_name = (name: string): void =>
+        this.update_game_settings((prev) => ({ ...prev, name: name }));
+    update_challenge_game_name: (name: string) => void =
+        this.props.mode === "demo" ? this.update_demo_name : this.update_game_name;
+
     update_private = (ev: React.ChangeEvent<HTMLInputElement>) =>
         this.upstate([
             [this.gameStateName("private"), ev],
@@ -804,8 +814,8 @@ export class ChallengeModalBody extends React.Component<
         this.upstate(this.gameStateName("height"), parseInt(ev.target.value));
     update_rules = (ev: React.ChangeEvent<HTMLSelectElement>) =>
         this.upstate(this.gameStateName("rules"), ev);
-    update_handicap = (ev: React.ChangeEvent<HTMLSelectElement>) =>
-        this.upstate("challenge.game.handicap", ev);
+    update_handicap = (handicap: number) =>
+        this.update_game_settings((prev) => ({ ...prev, handicap: handicap }));
 
     update_komi_auto = (ev: React.ChangeEvent<HTMLSelectElement>) => {
         const game = this.gameState();
@@ -969,7 +979,9 @@ export class ChallengeModalBody extends React.Component<
                                 <input
                                     type="text"
                                     value={this.gameState().name}
-                                    onChange={this.update_challenge_game_name}
+                                    onChange={(ev) =>
+                                        this.update_challenge_game_name(ev.target.value)
+                                    }
                                     className="form-control"
                                     id="challenge-game-name"
                                     placeholder={_("Game Name")}
@@ -1146,7 +1158,7 @@ export class ChallengeModalBody extends React.Component<
                     <div className="checkbox">
                         <select
                             value={game.handicap}
-                            onChange={this.update_handicap}
+                            onChange={(ev) => this.update_handicap(parseInt(ev.target.value))}
                             className="challenge-dropdown form-control"
                         >
                             <option
@@ -1749,7 +1761,7 @@ export class ChallengeModalBody extends React.Component<
                 width: this.state.challenge.game.width,
                 height: this.state.challenge.game.height,
                 ranked: true,
-                handicap: this.state.challenge.game.handicap !== "0",
+                handicap: this.state.challenge.game.handicap !== 0,
                 system: this.state.time_control.system,
                 speed: this.state.time_control.speed,
                 [this.state.time_control.system]: speed_settings,
@@ -1803,7 +1815,7 @@ export class ChallengeModalBody extends React.Component<
 
         const selected_bot_value = available_bots.find((b) => b.id === this.state.conf.bot_id);
         if (selected_bot_value?.disabled) {
-            this.upstate("conf.bot_id", 0);
+            this.update_bot_id(0);
         }
 
         return available_bots.length <= 0 ? (
@@ -1839,7 +1851,7 @@ export class ChallengeModalBody extends React.Component<
                                                 }
                                                 onClick={() => {
                                                     if (!bot.disabled) {
-                                                        this.upstate("conf.bot_id", bot.id);
+                                                        this.update_bot_id(bot.id);
                                                     }
                                                 }}
                                             >
diff --git a/src/components/ChallengeModal/ChallengeModal.types.ts b/src/components/ChallengeModal/ChallengeModal.types.ts
index e6822a269a..788c1a69b3 100644
--- a/src/components/ChallengeModal/ChallengeModal.types.ts
+++ b/src/components/ChallengeModal/ChallengeModal.types.ts
@@ -15,6 +15,7 @@
  * along with this program.  If not, see <http://www.gnu.org/licenses/>.
  */
 
+import { DataSchema } from "@/lib/data_schema";
 import { CreatedChallengeInfo, RuleSet } from "@/lib/types";
 import {
     GobanEngineInitialState,
@@ -22,6 +23,8 @@ import {
     JGOFTimeControlSpeed,
     JGOFTimeControlSystem,
 } from "goban";
+import { TimeControl } from "../TimeControl";
+import { ViewMode } from "@/views/Game";
 
 export type ChallengeDetails = rest_api.ChallengeDetails;
 
@@ -41,6 +44,14 @@ export interface ChallengeModalProperties {
     created?: (c: CreatedChallengeInfo) => void;
 }
 
+export interface ChallengeModalInput extends ChallengeModalProperties {
+    modal: {
+        close?: () => void;
+        on: (event: "open" | "close", callback: () => void) => void;
+        off: (event: "open" | "close", callback: () => void) => void;
+    };
+}
+
 /* These rejection details come from gtp2ogs and allows bots to
  * be clear about why a challenge is being rejected. */
 export interface RejectionDetails {
@@ -120,3 +131,46 @@ interface TimeControlConfig {
     periods?: number;
     pause_on_weekends: boolean;
 }
+
+export type ChallengeModalConf = {
+    mode: ChallengeModes;
+    username: string;
+    bot_id: number;
+    selected_board_size: string;
+    restrict_rank: boolean;
+};
+
+export type ChallengeModalGameSettings = ChallengeDetails["game"] & {
+    speed?: JGOFTimeControlSpeed;
+};
+
+export type ChallengeModalChallengeSettings = ChallengeDetails & {
+    invite_only?: boolean;
+    boardWidth?: number;
+    boardHeight?: number;
+    game: ChallengeModalGameSettings;
+};
+
+export type ChallengeModalDemoSettings = DataSchema["demo.settings"];
+
+export type ChallengeModalState = {
+    challenge: ChallengeModalChallengeSettings;
+    conf: ChallengeModalConf;
+    demo: ChallengeModalDemoSettings;
+    forking_game: boolean;
+    hide_preferred_settings_on_portrait: boolean;
+    input_value_warning: boolean;
+    preferred_settings: ChallengeDetails[];
+    selected_demo_player_black: number;
+    selected_demo_player_white: number;
+    time_control: TimeControl;
+    view_mode: ViewMode;
+    player_username_resolved?: boolean;
+    show_computer_settings?: boolean;
+    initial_state?: {
+        black: string;
+        white: string;
+    };
+};
+
+export type UpdateFn<T> = (prev: T) => T;
diff --git a/src/components/ChallengeModal/ChallengeModal.utils.ts b/src/components/ChallengeModal/ChallengeModal.utils.ts
index 43b83874cd..4dd3d61a40 100644
--- a/src/components/ChallengeModal/ChallengeModal.utils.ts
+++ b/src/components/ChallengeModal/ChallengeModal.utils.ts
@@ -204,3 +204,16 @@ export function rejectionDetailsToMessage(details: RejectionDetails): string | u
             return undefined;
     }
 }
+
+// For legacy reasons, handicap can have a string value
+// when these challenge details come from local storage.
+export function sanitizeChallengeDetails(challengeDetails: ChallengeDetails): ChallengeDetails {
+    return {
+        ...challengeDetails,
+        game: { ...challengeDetails.game, handicap: Number(challengeDetails.game.handicap) },
+    };
+}
+
+export function getPreferredSettings(): ChallengeDetails[] {
+    return data.get("preferred-game-settings", []).map(sanitizeChallengeDetails);
+}
diff --git a/src/components/ChallengeModal/index.ts b/src/components/ChallengeModal/index.ts
index 576ce2ed75..dea618bc6f 100644
--- a/src/components/ChallengeModal/index.ts
+++ b/src/components/ChallengeModal/index.ts
@@ -15,11 +15,6 @@
  * along with this program.  If not, see <http://www.gnu.org/licenses/>.
  */
 
-export * from "./ChallengeModal";
 export * from "./ChallengeModal.api";
 export { challenge_text_description } from "./ChallengeModal.utils";
-export type {
-    ChallengeModalProperties,
-    ChallengeModalConfig,
-    ChallengeDetails,
-} from "./ChallengeModal.types";
+export type { ChallengeModalConfig, ChallengeDetails } from "./ChallengeModal.types";
diff --git a/src/components/TimeControl/TimeControlUpdates.ts b/src/components/TimeControl/TimeControlUpdates.ts
index fe41fadfdf..030b91810b 100644
--- a/src/components/TimeControl/TimeControlUpdates.ts
+++ b/src/components/TimeControl/TimeControlUpdates.ts
@@ -48,8 +48,8 @@ export function updateSpeed(
 export function updateSystem(
     old: TimeControl,
     system: TimeControlSystem,
-    boardWidth: number,
-    boardHeight: number,
+    boardWidth?: number,
+    boardHeight?: number,
 ): TimeControl {
     return recallTimeControlSettings(old.speed, system, boardWidth, boardHeight);
 }


Reviews:
user: login: merowin
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-05T07:46:35Z
user: login: merowin
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-05T10:04:33Z
user: login: benjaminpjones
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-06T12:15:35Z
user: login: merowin
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-06T19:58:23Z
user: login: merowin
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-06T20:03:15Z

Review comments:
diff_hunk: @@ -120,3 +131,40 @@ interface TimeControlConfig {
     periods?: number;
     pause_on_weekends: boolean;
 }
+
+export type ChallengeModalConf = {
+    mode: ChallengeModes;
+    username: string;
+    bot_id: number;
+    selected_board_size: string;
+    restrict_rank: boolean;
+};
+
+export type ChallengeModalChallengeSettings = ChallengeDetails & {
+    invite_only?: boolean;
+    boardWidth?: number;
+    boardHeight?: number;
+    game: ChallengeDetails["game"] & { speed?: JGOFTimeControlSpeed };
+};
+
+export type ChallengeModalDemoSettings = DataSchema["demo.settings"];
+
+export type ChallengeModalState = {
path: src/components/ChallengeModal/ChallengeModal.types.ts
user: login: merowin
body: I tried to make this type such that it compiles with minimal changes.
created_at: 2025-04-05T07:46:35Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1749,7 +1758,7 @@ export class ChallengeModalBody extends React.Component<
                 width: this.state.challenge.game.width,
                 height: this.state.challenge.game.height,
                 ranked: true,
-                handicap: this.state.challenge.game.handicap !== "0",
+                handicap: this.state.challenge.game.handicap.toString() !== "0",
path: src/components/ChallengeModal/ChallengeModal.tsx
user: login: merowin
body: Typescript complained that a comparison between number and string is superfluous, so I added this conversion. I'm not sure what's going on though, did I get the type wrong, or is this comparison needed for legacy reasons?
created_at: 2025-04-05T10:04:33Z
author_association: CONTRIBUTOR
diff_hunk: @@ -752,10 +742,27 @@ export class ChallengeModalBody extends React.Component<
     };
 
     /* update bindings  */
-    update_conf_bot_id = (ev: React.ChangeEvent<HTMLSelectElement>) =>
-        this.upstate("conf.bot_id", parseInt(ev.target.value));
-    update_challenge_game_name = (ev: React.ChangeEvent<HTMLInputElement>) =>
-        this.upstate(this.gameStateName("name"), ev);
+
+    /* nested fn updates */
+    update_conf = (update_fn: UpdateFn<ChallengeModalConf>): void =>
+        this.setState((prev) => ({ ...prev, conf: update_fn(prev.conf) }));
path: src/components/ChallengeModal/ChallengeModal.tsx
user: login: benjaminpjones
body: If I understand [the docs](https://react.dev/reference/react/Component#setstate-parameters) correctly, the return value of the  callback will shallowly update state, and it's unnecessary to copy `...prev`.

```suggestion
    update_conf = (update_fn: UpdateFn<ChallengeModalConf>): void =>
        this.setState((prev) => ({ conf: update_fn(prev.conf) }));
```

> If you pass a function as `nextState`, it will be treated as an updater function. It must be pure, should take the pending state and props as arguments, and should return the object to be shallowly merged into this.state.
created_at: 2025-04-06T11:52:04Z
author_association: CONTRIBUTOR
diff_hunk: @@ -752,10 +742,27 @@ export class ChallengeModalBody extends React.Component<
     };
 
     /* update bindings  */
-    update_conf_bot_id = (ev: React.ChangeEvent<HTMLSelectElement>) =>
-        this.upstate("conf.bot_id", parseInt(ev.target.value));
-    update_challenge_game_name = (ev: React.ChangeEvent<HTMLInputElement>) =>
-        this.upstate(this.gameStateName("name"), ev);
+
+    /* nested fn updates */
+    update_conf = (update_fn: UpdateFn<ChallengeModalConf>): void =>
+        this.setState((prev) => ({ ...prev, conf: update_fn(prev.conf) }));
+    update_challenge_settings = (update_fn: UpdateFn<ChallengeModalChallengeSettings>): void =>
+        this.setState((prev) => ({ ...prev, challenge: update_fn(prev.challenge) }));
+    update_demo_settings = (update_fn: UpdateFn<ChallengeModalDemoSettings>): void =>
+        this.setState((prev) => ({ ...prev, demo: update_fn(prev.demo) }));
+    update_game_settings = (update_fn: UpdateFn<ChallengeModalGameSettings>): void =>
+        this.update_challenge_settings((prev) => ({ ...prev, game: update_fn(prev.game) }));
+
+    /* direct fn updates */
+    update_bot_id = (id: number) => this.update_conf((prev) => ({ ...prev, bot_id: id }));
path: src/components/ChallengeModal/ChallengeModal.tsx
user: login: benjaminpjones
body: Doesn't have to be in this PR, but part of the pain, I'm sure you noticed, is the heavy nesting of `state`.  It may be worthwhile to flatten the state object in the future to make this easier.

https://react.dev/learn/choosing-the-state-structure#avoid-deeply-nested-state
created_at: 2025-04-06T12:09:16Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1749,7 +1758,7 @@ export class ChallengeModalBody extends React.Component<
                 width: this.state.challenge.game.width,
                 height: this.state.challenge.game.height,
                 ranked: true,
-                handicap: this.state.challenge.game.handicap !== "0",
+                handicap: this.state.challenge.game.handicap.toString() !== "0",
path: src/components/ChallengeModal/ChallengeModal.tsx
user: login: benjaminpjones
body: If you're sure this is a number, then the original code looks like a bug to me.  I'd probably just do numeric comparison:

```suggestion
                handicap: this.state.challenge.game.handicap !== 0,
```
created_at: 2025-04-06T12:11:40Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1749,7 +1758,7 @@ export class ChallengeModalBody extends React.Component<
                 width: this.state.challenge.game.width,
                 height: this.state.challenge.game.height,
                 ranked: true,
-                handicap: this.state.challenge.game.handicap !== "0",
+                handicap: this.state.challenge.game.handicap.toString() !== "0",
path: src/components/ChallengeModal/ChallengeModal.tsx
user: login: merowin
body: I see now that string values can get into state because the input is a \<select\>. I intend to fix it by converting the string to number before it gets into the state.
created_at: 2025-04-06T19:58:23Z
author_association: CONTRIBUTOR
diff_hunk: @@ -752,10 +742,27 @@ export class ChallengeModalBody extends React.Component<
     };
 
     /* update bindings  */
-    update_conf_bot_id = (ev: React.ChangeEvent<HTMLSelectElement>) =>
-        this.upstate("conf.bot_id", parseInt(ev.target.value));
-    update_challenge_game_name = (ev: React.ChangeEvent<HTMLInputElement>) =>
-        this.upstate(this.gameStateName("name"), ev);
+
+    /* nested fn updates */
+    update_conf = (update_fn: UpdateFn<ChallengeModalConf>): void =>
+        this.setState((prev) => ({ ...prev, conf: update_fn(prev.conf) }));
+    update_challenge_settings = (update_fn: UpdateFn<ChallengeModalChallengeSettings>): void =>
+        this.setState((prev) => ({ ...prev, challenge: update_fn(prev.challenge) }));
+    update_demo_settings = (update_fn: UpdateFn<ChallengeModalDemoSettings>): void =>
+        this.setState((prev) => ({ ...prev, demo: update_fn(prev.demo) }));
+    update_game_settings = (update_fn: UpdateFn<ChallengeModalGameSettings>): void =>
+        this.update_challenge_settings((prev) => ({ ...prev, game: update_fn(prev.game) }));
+
+    /* direct fn updates */
+    update_bot_id = (id: number) => this.update_conf((prev) => ({ ...prev, bot_id: id }));
path: src/components/ChallengeModal/ChallengeModal.tsx
user: login: merowin
body: Also maybe the nesting can be reduced by extracting (or splitting) parts as individual components. I feel like the readability suffers a lot from the many different use-cases, and the giant file size.
created_at: 2025-04-06T20:03:14Z
author_association: CONTRIBUTOR

PR comments:
user: login: merowin
created_at: 2025-04-07T12:10:07Z
body: Converting to draft again, because I believe that I need to handle string values for handicap that come from local storage.
author_association: CONTRIBUTOR
user: login: anoek
created_at: 2025-04-09T11:26:05Z
body: Love where this is going, thanks!
author_association: MEMBER