diff --git a/jest.config.js b/jest.config.ts similarity index 84% rename from jest.config.js rename to jest.config.ts index aa4057260f..66a44f3323 100644 --- a/jest.config.js +++ b/jest.config.ts @@ -25,6 +25,9 @@ module.exports = { "src", "node_modules" ], + "moduleNameMapper": { + '^@/(.*)': '/src/$1', + }, "setupFiles": ["./setup-jest.js"], - "setupFilesAfterEnv": ["jest-chain"], + "setupFilesAfterEnv": ["jest-chain", "@testing-library/jest-dom"], } diff --git a/package.json b/package.json index 132954387e..d5f9ca5988 100644 --- a/package.json +++ b/package.json @@ -149,6 +149,7 @@ "sanitize-html": "^2.12.1", "sweetalert2": "^11.4.17", "ts-md5": "^1.3.1", + "ts-node": "^10.9.2", "valid-url": "^1.0.9", "whatwg-fetch": "^3.0.0" }, diff --git a/src/components/ChallengeModal/ChallengeModal.test.tsx b/src/components/ChallengeModal/ChallengeModal.test.tsx new file mode 100644 index 0000000000..b95c98ad7e --- /dev/null +++ b/src/components/ChallengeModal/ChallengeModal.test.tsx @@ -0,0 +1,89 @@ +/* + * Copyright (C) Online-Go.com + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +/* cspell: words groupadmin cotsen */ +import * as React from "react"; + +import * as ChallengeModal from "./ChallengeModal"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { ModalProvider } from "../Modal/ModalProvider"; +import { ModalContext, ModalTypes } from "../Modal/ModalContext"; +import * as DynamicHelp from "react-dynamic-help"; + +jest.mock("./../Modal", () => { + return { + ...jest.requireActual("./../Modal"), + }; +}); + +describe("ChallengeModal", () => { + it("should do a computer challenge via provider", () => { + const challengeModalSpy = jest + .spyOn(ChallengeModal, "ChallengeModal") + .mockImplementation(jest.fn()); + + render( + + + {({ showModal }) => { + showModal(ModalTypes.Challenge); + return null; + }} + + , + ); + + expect(challengeModalSpy.mock.calls[0][0]).toStrictEqual({ + mode: "computer", + initialState: null, + playerId: undefined, + }); + + challengeModalSpy.mockRestore(); + }); + + it("should close", () => { + const DynamicHelpProviderValue = { + registerTargetItem: jest.fn().mockReturnValue({ ref: jest.fn() }), + triggerFlow: jest.fn(), + enableHelp: jest.fn(), + getFlowInfo: jest.fn(), + enableFlow: jest.fn(), + reloadUserState: jest.fn(), + signalUsed: jest.fn(), + getSystemStatus: jest.fn(), + resetHelp: jest.fn(), + }; + + render( + + + + {({ showModal }) => { + showModal(ModalTypes.Challenge); + return null; + }} + + + , + ); + const closeButton = screen.getByText("Close"); + expect(closeButton).toBeInTheDocument(); + fireEvent.click(closeButton); + expect(closeButton).not.toBeInTheDocument(); + }); +}); diff --git a/src/components/ChallengeModal/ChallengeModal.tsx b/src/components/ChallengeModal/ChallengeModal.tsx index bc94dd8edc..26338de035 100644 --- a/src/components/ChallengeModal/ChallengeModal.tsx +++ b/src/components/ChallengeModal/ChallengeModal.tsx @@ -61,10 +61,11 @@ import { saveTimeControlSettings, updateSystem, } from "@/components/TimeControl/TimeControlUpdates"; +import { ActivateTooltip } from "@/views/HelpFlows/ModalHelp"; export type ChallengeDetails = rest_api.ChallengeDetails; -type ChallengeModes = "open" | "computer" | "player" | "demo"; +export type ChallengeModes = "open" | "computer" | "player" | "demo"; interface Events {} @@ -1778,7 +1779,11 @@ export class ChallengeModalBody extends React.Component<   {player_username} )} - {mode === "computer" && {_("Computer")}} + {mode === "computer" && ( + + {_("Computer")} + + )}
@@ -1960,9 +1965,6 @@ export function createDemoBoard( />, ); } -export function challengeComputer() { - return challenge(undefined, null, true); -} export function challengeRematch( goban: GobanRenderer, opponent: GobanEnginePlayerEntry, diff --git a/src/components/ChallengeModal/ForkModal.tsx b/src/components/ChallengeModal/ForkModal.tsx index 3cf91fd68e..2d4f9a4342 100644 --- a/src/components/ChallengeModal/ForkModal.tsx +++ b/src/components/ChallengeModal/ForkModal.tsx @@ -18,94 +18,69 @@ import * as React from "react"; import * as data from "@/lib/data"; import { _ } from "@/lib/translate"; -import { Modal, openModal } from "@/components/Modal"; import { GobanRenderer } from "goban"; import { PlayerAutocomplete } from "@/components/PlayerAutocomplete"; import { MiniGoban } from "@/components/MiniGoban"; import { challenge } from "@/components/ChallengeModal"; import { PlayerCacheEntry } from "@/lib/player_cache"; - -interface Events {} +import { ModalContext } from "../Modal/ModalContext"; interface ForkModalProperties { goban: GobanRenderer; } -export class ForkModal extends Modal { - constructor(props: ForkModalProperties) { - super(props); +export const ForkModal = ({ goban }: ForkModalProperties) => { + const [currPlayer, setCurrPlayer] = React.useState(null as PlayerCacheEntry | null); + const { hideModal } = React.useContext(ModalContext); - const goban = this.props.goban; - this.state = { - player: null, - fork_preview: { - //"moves": goban.engine.cur_move.getMoveStringToThisPoint(), - //"initial_state": goban.engine.initial_state, - //"initial_player": goban.engine.config.initial_player, - moves: [], - initial_state: goban.engine.computeInitialStateForForkedGame(), - initial_player: goban.engine.colorToMove(), - width: goban.engine.width, - height: goban.engine.height, - rules: goban.engine.rules, - handicap: goban.engine.handicap, - komi: goban.engine.komi, - move_number: goban.engine.getMoveNumber(), - game_name: goban.engine.name, - }, - }; - } + const forkPreview = { + moves: [], + initial_state: goban.engine.computeInitialStateForForkedGame(), + initial_player: goban.engine.colorToMove(), + width: goban.engine.width, + height: goban.engine.height, + rules: goban.engine.rules, + handicap: goban.engine.handicap, + komi: goban.engine.komi, + move_number: goban.engine.getMoveNumber(), + game_name: goban.engine.name, + }; - openChallengeModal = () => { - this.close(); - challenge(this.state.player.id, this.state.fork_preview); + const openChallengeModal = () => { + hideModal(); + if (currPlayer) { + challenge(currPlayer.id, forkPreview); + } }; - setPlayer = (player: PlayerCacheEntry | null) => { - this.setState({ player: player }); + const setPlayer = (player: PlayerCacheEntry | null) => { + setCurrPlayer(player); }; - render() { - return ( -
-
-

{_("Player to challenge")}:

{" "} - -
-
- -
-
- - -
+ return ( +
+
+

{_("Player to challenge")}:

- ); - } -} - -export function openForkModal(goban: GobanRenderer) { - return openModal(); -} -export function challengeFromBoardPosition(goban: GobanRenderer) { - if (!goban) { - return; - } - - openForkModal(goban); -} +
+ +
+
+ + +
+
+ ); +}; diff --git a/src/components/LanguagePicker/LanguagePicker.tsx b/src/components/LanguagePicker/LanguagePicker.tsx index efebaa7bb0..815d4c3a1f 100644 --- a/src/components/LanguagePicker/LanguagePicker.tsx +++ b/src/components/LanguagePicker/LanguagePicker.tsx @@ -17,19 +17,8 @@ import * as React from "react"; import { _, setCurrentLanguage, current_language, languages } from "@/lib/translate"; -import { Modal, openModal } from "@/components/Modal"; import * as preferences from "@/lib/preferences"; - -interface Events {} - -interface LanguagePickerProperties {} - -let language_modal: JSX.Element | null = null; - -function openLanguageModal() { - language_modal = ; - openModal(language_modal); -} +import { ModalContext, ModalTypes } from "../Modal/ModalContext"; function language_sorter(a: string, b: string) { if (a === "auto") { @@ -47,65 +36,64 @@ function language_sorter(a: string, b: string) { return 0; } -export const LanguagePicker = () => ( - - - {languages[current_language]} - -); +export const LanguagePicker = () => { + const { showModal } = React.useContext(ModalContext); -class LanguagePickerModal extends Modal { - constructor(props: LanguagePickerProperties) { - super(props); - this.state = { - selected_language: current_language, - }; - } + return ( + showModal(ModalTypes.LanguagePicker)} + > + + {languages[current_language]} + + ); +}; + +export const LanguagePickerModal = () => { + const { hideModal } = React.useContext(ModalContext); - setLanguage(language_code: string) { + const setLanguage = (language_code: string) => { preferences.set("language", language_code); setCurrentLanguage(language_code); - this.close(); window.location.reload(); - } + }; - render() { - const auto = preferences.get("language") === "auto"; - function computeClass(lc: string) { - let ret = ""; - if (auto) { - if (lc === "auto") { - ret += "selected"; - } else if (lc === current_language) { - ret += "auto"; - } - } else { - if (lc === current_language) { - ret += "selected"; - } + const auto = preferences.get("language") === "auto"; + function computeClass(lc: string) { + let ret = ""; + if (auto) { + if (lc === "auto") { + ret += "selected"; + } else if (lc === current_language) { + ret += "auto"; + } + } else { + if (lc === current_language) { + ret += "selected"; } - return ret; } + return ret; + } - return ( -
-
- {Object.keys(languages) - .sort(language_sorter) - .map((lc, idx) => ( - this.setLanguage(lc)} - > - {languages[lc]} - - ))} -
-
- -
+ return ( +
+
+ {Object.keys(languages) + .sort(language_sorter) + .map((lc, idx) => ( + setLanguage(lc)} + > + {languages[lc]} + + ))}
- ); - } -} +
+ +
+
+ ); +}; diff --git a/src/components/Modal/Modal.tsx b/src/components/Modal/Modal.tsx index a264b15bd5..bdb2cc897f 100644 --- a/src/components/Modal/Modal.tsx +++ b/src/components/Modal/Modal.tsx @@ -27,7 +27,7 @@ type ModalProps

= P & { fastDismiss?: boolean }; export type ModalConstructorInput

= ModalProps

| Readonly>; export class Modal extends TypedEventEmitterPureComponent< Events & { close: never; open: never }, - P & { fastDismiss?: boolean }, + P & { fastDismiss?: boolean; onClose?: () => void }, S > { constructor(props: ModalConstructorInput

) { @@ -39,6 +39,7 @@ export class Modal extends TypedEventEmitterPureComponent< } close = () => { + this.props.onClose && this.props.onClose(); this.emit("close"); }; bindContainer(container: HTMLElement) { diff --git a/src/components/Modal/ModalContext.tsx b/src/components/Modal/ModalContext.tsx new file mode 100644 index 0000000000..c244a05225 --- /dev/null +++ b/src/components/Modal/ModalContext.tsx @@ -0,0 +1,36 @@ +/* + * Copyright (C) Online-Go.com + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import { createContext } from "react"; + +export enum ModalTypes { + Challenge = "challenge", + LanguagePicker = "languagePicker", + Fork = "fork", +} + +type ModalContextProps = { + showModal: (type: ModalTypes, props?: any) => void; + hideModal: () => void; +}; + +const defaultModalContext: ModalContextProps = { + showModal: () => {}, + hideModal: () => {}, +}; + +export const ModalContext = createContext(defaultModalContext); diff --git a/src/components/Modal/ModalProvider.tsx b/src/components/Modal/ModalProvider.tsx new file mode 100644 index 0000000000..b41c99d01c --- /dev/null +++ b/src/components/Modal/ModalProvider.tsx @@ -0,0 +1,100 @@ +/* + * Copyright (C) Online-Go.com + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +/* cspell: words groupadmin cotsen */ +import * as React from "react"; + +import { ChallengeModes } from "../ChallengeModal"; +import { createPortal } from "react-dom"; +import { GobanRenderer } from "goban"; +import { ModalContext, ModalTypes } from "./ModalContext"; +import { modalRegistry } from "./ModalRegistry"; + +interface Modals { + challenge: { + mode: ChallengeModes; + playerId?: number; + initialState: any; + }; + fork: { + goban: GobanRenderer; + }; +} + +type ModalTypesProps = { + [key: string]: any; +}; + +export const ModalProvider = ({ children }: React.PropsWithChildren): JSX.Element => { + const [modalType, setModalType] = React.useState(null as ModalTypes | null); + const [modalProps, setModalProps] = React.useState({} as ModalTypesProps); + + const showModal = (type: ModalTypes, props?: any) => { + setModalType(type); + + switch (type) { + case ModalTypes.Challenge: + setModalProps({ + mode: "computer" as ChallengeModes, + initialState: null, + playerId: undefined, + }); + break; + case ModalTypes.Fork: + setModalProps({ + goban: (props as Modals["fork"]).goban, + }); + break; + default: + break; + } + }; + + const hideModal = () => { + setModalType(null); + }; + + React.useEffect(() => { + const handleEscape = (event: KeyboardEvent) => { + if (event.key === "Escape" && modalType) { + hideModal(); + } + }; + + document.addEventListener("keydown", handleEscape); + + return () => { + document.removeEventListener("keydown", handleEscape); + }; + }, [modalType, hideModal]); + + return ( + + {modalType && + createPortal( +

+ {React.createElement(modalRegistry[modalType], { + ...modalProps, + onClose: hideModal, + })} +
, + document.body, + )} + {children} + + ); +}; diff --git a/src/components/Modal/ModalRegistry.ts b/src/components/Modal/ModalRegistry.ts new file mode 100644 index 0000000000..70cfb66555 --- /dev/null +++ b/src/components/Modal/ModalRegistry.ts @@ -0,0 +1,38 @@ +/* + * Copyright (C) Online-Go.com + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +import { ForkModal } from "../ChallengeModal/ForkModal"; +import { ChallengeModal } from "../ChallengeModal/ChallengeModal"; +import { LanguagePickerModal } from "../LanguagePicker/LanguagePicker"; +import { ModalTypes } from "./ModalContext"; + +interface ModalRegistry { + [key: string]: React.ComponentType; +} + +export const modalRegistry: ModalRegistry = { + [ModalTypes.Fork]: ForkModal, + [ModalTypes.Challenge]: ChallengeModal, + [ModalTypes.LanguagePicker]: LanguagePickerModal, +}; + +export const registerModal = (modalType: string, component: React.ComponentType) => { + modalRegistry[modalType] = component; +}; + +export const unregisterModal = (modalType: string) => { + delete modalRegistry[modalType]; +}; diff --git a/src/main.tsx b/src/main.tsx index 3128c79383..f28935d865 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -328,7 +328,7 @@ const react_root = ReactDOM.createRoot(document.getElementById("main-content") a react_root.render( - {routes} + {routes} , @@ -339,4 +339,5 @@ window.preferences = preferences; window.player_cache = player_cache; import * as requests from "@/lib/requests"; +import { ModalProvider } from "./components/Modal/ModalProvider"; window.requests = requests; diff --git a/src/views/Game/GameDock.tsx b/src/views/Game/GameDock.tsx index b3d490e6fc..3fcc505c58 100644 --- a/src/views/Game/GameDock.tsx +++ b/src/views/Game/GameDock.tsx @@ -30,7 +30,6 @@ import { openGameLinkModal } from "./GameLinkModal"; import { openGameLogModal } from "./GameLogModal"; import { sfx } from "@/lib/sfx"; import { alert } from "@/lib/swal_config"; -import { challengeFromBoardPosition } from "@/components/ChallengeModal/ForkModal"; import { errorAlerter } from "@/lib/misc"; import { doAnnul } from "@/lib/moderation"; import { openReport } from "@/components/Report"; @@ -39,6 +38,23 @@ import { openGameInfoModal } from "./GameInfoModal"; import { useUserIsParticipant } from "./GameHooks"; import { useGoban } from "./goban_context"; import { Tooltip } from "../../components/Tooltip"; +import { ModalContext, ModalTypes } from "@/components/Modal/ModalContext"; +import { GobanEngine, GobanRenderer } from "goban"; + +const handleForkGameClick = ( + showModal: (type: ModalTypes, props?: any) => void, + user: rest_api.UserConfig, + engine: GobanEngine, + goban: GobanRenderer, +) => { + if (!user.anonymous && !engine.rengo && !goban.isAnalysisDisabled()) { + if (!goban) { + return; + } + + showModal(ModalTypes.Fork, { goban }); + } +}; interface DockProps { annulled: boolean; @@ -92,6 +108,7 @@ export function GameDock({ const phase = engine.phase; const user = useUser(); + const { showModal } = React.useContext(ModalContext); const tooltipRequired = preferences.get("dock-delay") === MAX_DOCK_DELAY; @@ -139,11 +156,6 @@ export function GameDock({ } }; - const fork = () => { - if (!user.anonymous && !engine.rengo && !goban.isAnalysisDisabled()) { - challengeFromBoardPosition(goban); - } - }; const showLinkModal = () => { openGameLinkModal(goban); }; @@ -447,7 +459,7 @@ export function GameDock({ handleForkGameClick(showModal, user, engine, goban)} className={ user.anonymous || engine.rengo || goban.isAnalysisDisabled() ? "disabled" diff --git a/src/views/HelpFlows/HelpFlows.tsx b/src/views/HelpFlows/HelpFlows.tsx index b218c0cef1..5199cd3c9d 100644 --- a/src/views/HelpFlows/HelpFlows.tsx +++ b/src/views/HelpFlows/HelpFlows.tsx @@ -29,6 +29,7 @@ import { UndoRequestReceivedIntro } from "./UndoIntro"; import { CommunityModeratorIntro } from "./CommunityModeratorIntro"; import { OJEIntro } from "./OJEIntro"; import { GameLogHelp } from "./GameLogHelp"; +import { ModalHelp } from "./ModalHelp"; /** * This component is a handy wrapper for all the Help Flows, and reset on login/logout @@ -80,7 +81,7 @@ export function HelpFlows(): JSX.Element { - + diff --git a/src/views/HelpFlows/ModalHelp.tsx b/src/views/HelpFlows/ModalHelp.tsx new file mode 100644 index 0000000000..91e51c3018 --- /dev/null +++ b/src/views/HelpFlows/ModalHelp.tsx @@ -0,0 +1,49 @@ +/* + * Copyright (C) Online-Go.com + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +/* cspell: words groupadmin cotsen */ + +import * as React from "react"; + +import { HelpFlow, HelpItem } from "react-dynamic-help"; +import * as DynamicHelp from "react-dynamic-help"; + +export function ModalHelp(): JSX.Element { + return ( + + +
test
+
+
+ ); +} + +interface ActivateTooltipProps { + children: React.ReactNode; + flow: string; + item: string; +} + +export const ActivateTooltip = ({ children, flow, item }: ActivateTooltipProps) => ( + + {(value) => { + const { ref: modalHelpIntro } = value.registerTargetItem(`${flow}-${item}`); + value.triggerFlow(flow); + return
{children}
; + }} +
+); diff --git a/src/views/Play/Play.tsx b/src/views/Play/Play.tsx index 8913e9198f..a2b49c5566 100644 --- a/src/views/Play/Play.tsx +++ b/src/views/Play/Play.tsx @@ -35,7 +35,7 @@ import { timeControlSystemText, usedForCheating, } from "@/components/TimeControl"; -import { challenge, challengeComputer } from "@/components/ChallengeModal"; +import { challenge } from "@/components/ChallengeModal"; import { openGameAcceptModal } from "@/components/GameAcceptModal"; import { errorAlerter, rulesText, dup, uuid, ignore } from "@/lib/misc"; import { Player } from "@/components/Player"; @@ -57,9 +57,18 @@ import { ChallengeFilterKey, shouldDisplayChallenge, } from "@/lib/challenge_utils"; +import { ModalContext, ModalTypes } from "@/components/Modal/ModalContext"; const CHALLENGE_LIST_FREEZE_PERIOD = 1000; // Freeze challenge list for this period while they move their mouse on it +const handleComputerChallengeClick = (showModal: (type: ModalTypes, props?: any) => void) => { + if (bot_count() === 0) { + void alert.fire(_("Sorry, all bots seem to be offline, please try again later.")); + return; + } + showModal(ModalTypes.Challenge); +}; + interface PlayState { live_list: Array; correspondence_list: Array; @@ -379,14 +388,6 @@ export class Play extends React.Component<{}, PlayState> { this.forceUpdate(); }; - newComputerGame = () => { - if (bot_count() === 0) { - void alert.fire(_("Sorry, all bots seem to be offline, please try again later.")); - return; - } - challengeComputer(); - }; - newCustomGame = () => { challenge(undefined, undefined, undefined, undefined, this.challengeCreated); }; @@ -828,16 +829,20 @@ export class Play extends React.Component<{}, PlayState> {
- + + {({ showModal }) => ( + + )} +