Compare commits

..

4 Commits

Author SHA1 Message Date
04f0b86527 api.push (#36)
Reviewed-on: http://192.168.8.55:3000/HQLAx/CordaCheckers/pulls/36
2023-11-13 16:55:10 +01:00
2482226e0e Better useXxxApi (#34)
useXxxApi:

    use PollingReducer as a configuration provider
    provide pushAPIs, which update respective state with:
        pushing status
        push result

PollingReducer:
    rename to configurationReducer
    move polling indication to it's respective state
    XxxState.polling = 'true/false'

Reviewed-on: http://192.168.8.55:3000/HQLAx/CordaCheckers/pulls/34
2023-11-12 19:40:55 +01:00
82f5b07256 action readyiness indication 2023-11-11 11:37:17 +01:00
703a6a0326 skeleton 2023-11-11 11:08:48 +01:00
16 changed files with 264 additions and 136 deletions

View File

@ -9,7 +9,9 @@ import About from "./components/About"
import Games from './container/Games'; import Games from './container/Games';
import Leaderboard from './container/Leaderboard'; import Leaderboard from './container/Leaderboard';
import usePollingReducer from './reducer/polling'; import useConfigReducer from './reducer/config';
import useUserReducer from './reducer/user';
import useLeaderboardReducer from './reducer/leaderboard';
import useGamesReducer from './reducer/games'; import useGamesReducer from './reducer/games';
import useUserApi from './api/user'; import useUserApi from './api/user';
@ -17,35 +19,40 @@ import useLeaderboardApi from './api/leaderboard';
import useGamesApi from './api/games'; import useGamesApi from './api/games';
export default function App() { export default function App() {
const pollingReducer = usePollingReducer(); const [config, dispatcConfig] = useConfigReducer();
const leaderboard = useLeaderboardApi().poll(pollingReducer); const user = useUserApi(useUserReducer()).getUser();
const user = useUserApi().get(); const leaderboard = useLeaderboardApi(useLeaderboardReducer(), config).pollTable();
const [games, dispatchGames] = useGamesReducer();
const gamesApi = useGamesApi(dispatchGames);
gamesApi.list(pollingReducer);
const players = { const players = {
leaderboard, leaderboard,
isCurrentUser: (playerName) => user?.isCurrentUser(playerName) === true ? true : null isCurrentUser: (playerName) => user?.isCurrentUser(playerName) === true ? true : null
}; };
const gamesReducer = useGamesReducer();
const gamesApi = useGamesApi(gamesReducer, config);
const games = gamesApi.pollGamesList();
const isPolling = {
games: games.isPollingGamesList,
leaderboard: leaderboard.isPollingTable
}
return ( return (
<BrowserRouter> <BrowserRouter>
<Header pollingReducer={pollingReducer} /> <Header configReducer={[config, dispatcConfig]} isPolling={isPolling}/>
<Routes> <Routes>
<Route path='/' element={<About />} /> <Route path='/' element={<About />} />
<Route path='/about' element={<About />} /> <Route path='/about' element={<About />} />
<Route path='/games/*' element={<Games context={{ games, dispatchGames, gamesApi }} players={players} />} /> <Route path='/games/*' element={<Games context={{ gamesReducer, gamesApi }} players={players} />} />
<Route path='/leaderboard' element={<Leaderboard players={players} />} /> <Route path='/leaderboard' element={<Leaderboard players={players} />} />
</Routes> </Routes>
</BrowserRouter> </BrowserRouter>
) )
} }
function Header({ pollingReducer }) { function Header({ configReducer, isPolling }) {
const [polling, dispatchPolling] = pollingReducer; const [config, dispatcConfig] = configReducer;
return ( return (
<div className='Header'> <div className='Header'>
@ -54,8 +61,8 @@ function Header({ pollingReducer }) {
</h1> </h1>
<OnlineToggle <OnlineToggle
isOnline={polling.enabled} isOnline={config.online}
onClick={() => dispatchPolling({ type: 'toggleOnOff' })} onClick={() => dispatcConfig({ type: 'toggleOnline' })}
/> />
<nav> <nav>
@ -64,11 +71,11 @@ function Header({ pollingReducer }) {
</NavLink> </NavLink>
<NavLink to='/games'> <NavLink to='/games'>
<Wobler text="Games" dance={polling.games} /> <Wobler text="Games" dance={isPolling.games} />
</NavLink> </NavLink>
<NavLink to='/leaderboard'> <NavLink to='/leaderboard'>
<Wobler text='Leaderboard' dance={polling.leaderboard} /> <Wobler text='Leaderboard' dance={isPolling.leaderboard} />
</NavLink> </NavLink>
</nav> </nav>
</div> </div>

View File

@ -1,26 +1,28 @@
import usePolling from '../hook/Polling'; import { usePolling, doPushing } from '../hook/api';
export default function useGamesApi(dispatchGames) { export default function useGamesApi(gamesReducer, config) {
const [games, dispatchGames] = gamesReducer;
const useList = (pollingReducer) => { const usePollingGamesList = () => {
const [polling, dispatchPolling] = pollingReducer; const onSuccess = (gamesList) => {
dispatchGames({ type: 'next', gamesList });
const onResponce = (json) => {
dispatchGames({ type: 'next', list: json });
} }
const mode = (polling.enabled === true) const isPollingGamesList = usePolling('/api/games', onSuccess, config.intervalMode(30));
? { interval_sec: 30 } // fetch gamesList every half a minue if (games.isPollingGamesList !== isPollingGamesList) {
: { interval_stop: true } // user has fliped OfflineToggel dispatchGames({ type: 'next', isPollingGamesList });
const isPolling = usePolling('/api/games', onResponce, mode);
if (isPolling !== polling.games) {
dispatchPolling({ type: 'next', games: isPolling });
} }
return games;
} }
return { return {
list: useList pollGamesList: usePollingGamesList,
pushNewGame: (reqParams) => doPushing('/api/gameproposal', 'POST', reqParams, {
onPushing: (isPushingNewGame) => dispatchGames({ type: 'next', isPushingNewGame }),
onSuccess: (game) => dispatchGames({ type: 'next', gamesList: [...games.gamesList, game] })
}),
} }
} }

View File

@ -1,26 +1,22 @@
import { useState } from "react"; import { usePolling } from "../hook/api";
import usePolling from "../hook/Polling";
export default function useLeaderboardApi() { export default function useLeaderboardApi(leaderboardReducer, config) {
const [leaderboard, setLeaderboard] = useState(null); const [leaderboard, dispatchLeaderboard] = leaderboardReducer;
const usePoll = (pollingReducer) => { const usePollingTable = () => {
const [polling, dispatchPolling] = pollingReducer; const onSuccess = (table) => {
dispatchLeaderboard({ type: 'next', table });
}
const mode = (polling.enabled === true) const isPollingTable = usePolling('/api/leaderboard', onSuccess, config.intervalMode(300));
? { interval_sec: 300 } // update leaderbord stats every 5 min if (leaderboard.isPollingTable !== isPollingTable) {
: { interval_stop: true } // user has fliped OfflineToggel dispatchLeaderboard({ type: 'next', isPollingTable });
const isPolling = usePolling('/api/leaderboard', setLeaderboard, mode);
if (isPolling !== polling.leaderboard) {
dispatchPolling({ type: 'next', leaderboard: isPolling });
} }
return leaderboard; return leaderboard;
} }
return { return {
poll: usePoll pollTable: usePollingTable
} }
} }

View File

@ -1,20 +1,19 @@
import usePolling from "../hook/Polling"; import { usePolling } from "../hook/api";
import useUserReducer from "../reducer/user";
export default function useUserApi() { export default function useUserApi(userReducer) {
const [user, dispatchUser] = useUserReducer(); const [user, dispatchUser] = userReducer;
const useGet = () => { const useGetUser = () => {
const onResponce = (json) => { const onSuccess = (userJson) => {
dispatchUser({ type: "parse", json }); dispatchUser({ type: "parse", userJson });
} }
usePolling('/api/user', onResponce); // <<-- fetch once usePolling('/api/user', onSuccess); // <<-- fetch once
return user; return user;
} }
return { return {
get: useGet getUser: useGetUser
} }
} }

View File

@ -97,16 +97,19 @@
margin: 2px; margin: 2px;
} }
.ActionPanel .Create:hover, /* OR */ .ActionPanel .Create.ready /* , */ /* OR .game-action.busy */
.game-action.busy
{ {
background-color:#00b0ff60; background-color:#00b0ff60;
} }
.ActionPanel .Create.enabled:active { .ActionPanel .Create.ready:hover {
background-color:#00b0ffa0; background-color:#00b0ffa0;
} }
.ActionPanel .Create.ready:active {
background-color:#00b0fff0;
}
.ActionPanel .Cancel:hover, .ActionPanel .Cancel:hover,
.ActionPanel .Reject:hover { .ActionPanel .Reject:hover {
background-color:#ff000030 background-color:#ff000030

View File

@ -19,7 +19,8 @@ import GameBoard from './games/GameBoard';
import './Games.css'; import './Games.css';
export default function Games({ context: { games, dispatchGames, gamesApi }, players }) { export default function Games({ context: { gamesReducer, gamesApi }, players }) {
const [games, dispatchGames] = gamesReducer;
return ( return (
<GamesContext.Provider value={games} > <GamesContext.Provider value={games} >
@ -29,7 +30,7 @@ export default function Games({ context: { games, dispatchGames, gamesApi }, pla
<ViewProvider players={players} dispatchGames={dispatchGames} /> <ViewProvider players={players} dispatchGames={dispatchGames} />
</div> </div>
<div className='right-side'> <div className='right-side'>
<ActionPanel /> <ActionPanel players={players} gamesApi={gamesApi} />
<GameBoard /> <GameBoard />
{/* {/*
<GameMessage /> <GameMessage />
@ -80,11 +81,15 @@ function ViewProvider({ players, dispatchGames }) {
) )
} }
function ActionPanel() { function ActionPanel({ players, gamesApi }) {
return ( return (
<div className='ActionPanel'> <div className='ActionPanel'>
<Routes> <Routes>
<Route path='new' element={<Create />} /> <Route path='new' element={
<Create isCurrentUser={players.isCurrentUser}
pushNewGame={(reqParams) => gamesApi.pushNewGame(reqParams)}
/>
} />
<Route path='proposal' element={[<Accept key={1} />, <Reject key={2} />, <Cancel key={3} />]} /> <Route path='proposal' element={[<Accept key={1} />, <Reject key={2} />, <Cancel key={3} />]} />
<Route path='active' element={[<DrawReq key={1} />, <DrawAcq key={2} />, <Surrender key={3} />]} /> <Route path='active' element={[<DrawReq key={1} />, <DrawAcq key={2} />, <Surrender key={3} />]} />
<Route path='archive' element={[<Backward key={1} />, <Forward key={2} />]} /> <Route path='archive' element={[<Backward key={1} />, <Forward key={2} />]} />

View File

@ -4,13 +4,12 @@ import Loading from '../components/Loading';
export default function Leaderboard({ players }) { export default function Leaderboard({ players }) {
const leaderboard = players.leaderboard; const table = players.leaderboard.table;
if (table == null)
if (leaderboard == null)
return <Loading /> return <Loading />
const tableRows = Object.keys(leaderboard).map(playerName => { const tableRows = Object.keys(table).map(playerName => {
var rank = leaderboard[playerName]; var rank = table[playerName];
return <tr key={playerName} className={players.isCurrentUser(playerName) && 'currentuser'}> return <tr key={playerName} className={players.isCurrentUser(playerName) && 'currentuser'}>
<td>{playerName}</td> <td>{playerName}</td>

View File

@ -1,6 +1,66 @@
import React from 'react'; import React, { useContext } from 'react';
import { GamesContext } from '../../../context/games';
import Wobler from '../../../components/Wobler';
import { Color } from '../../../components/Checkers';
export default function Create() {
return <button className='Create'>Create</button> export default function Create({ isCurrentUser, pushNewGame }) {
const games = useContext(GamesContext);
const hasPlayers = checkPlayers(games.newGame);
const hasCurrentUser = checkCurrentUser(games.newGame, isCurrentUser);
const prepareNewGameRequest = () => {
if (!hasPlayers)
return alert("Black and White players must be selected for the game");
if (!hasCurrentUser)
return alert("You must be one of the selected players");
if (games.isPushingNewGame)
return; // current request is still being processed
/*
* Prepare & send NewGame request
*/
const [opponentName, opponentColor] = getOpponent(games.newGame, isCurrentUser);
const reqParams = {
opponentName,
opponentColor,
board: null, // default board configuration
message: 'default NewGame req message'
}
pushNewGame(reqParams);
}
return (
<button className={'Create' + (hasPlayers && hasCurrentUser ? ' ready' : '')}
onClick={prepareNewGameRequest}
>
<Wobler text="Create" dance={games.isPushingNewGame} />
</button>
)
}
function checkPlayers({ whitePlayer, blackPlayer }) {
return whitePlayer && blackPlayer
&& whitePlayer !== blackPlayer;
}
function checkCurrentUser({ whitePlayer, blackPlayer }, isCurrentUser) {
return isCurrentUser(whitePlayer) || isCurrentUser(blackPlayer);
}
function getOpponent({whitePlayer, blackPlayer}, isCurrentUser) {
if (isCurrentUser(whitePlayer)) {
return [blackPlayer, Color.black];
}
if (isCurrentUser(blackPlayer)) {
return [whitePlayer, Color.white];
}
return ['', ''];
} }

View File

@ -7,14 +7,14 @@ import Loading from '../../../components/Loading';
export default function GameSelector({ yours, opponents, onClick }) { export default function GameSelector({ yours, opponents, onClick }) {
const games = useContext(GamesContext); const gamesList = useContext(GamesContext).gamesList;
if (games.list === null) if (gamesList === null)
return <Loading /> return <Loading />
const yoursList = games.list.filter(game => game.status === yours) const yoursList = gamesList.filter(game => game.status === yours)
.map(game => <Selectable game={game} key={game.uuid} onClick={onClick} />) .map(game => <Selectable game={game} key={game.uuid} onClick={onClick} />)
const opponentsList = games.list.filter(game => game.status === opponents) const opponentsList = gamesList.filter(game => game.status === opponents)
.map(game => <Selectable game={game} key={game.uuid} onClick={onClick} />) .map(game => <Selectable game={game} key={game.uuid} onClick={onClick} />)
return ( return (

View File

@ -11,9 +11,9 @@ export default function NewGame({ players, onSelectPlayer }) {
/* /*
* Name options * Name options
*/ */
const nameOptions = !players.leaderboard const nameOptions = !players.leaderboard.table
? [<option key='loading' value='…'>loading</option>] ? [<option key='loading' value='…'>loading</option>]
: Object.keys(players.leaderboard).map(playerName => : Object.keys(players.leaderboard.table).map(playerName =>
<option key={playerName} value={playerName}> <option key={playerName} value={playerName}>
{players.isCurrentUser(playerName) ? 'You' : playerName} {players.isCurrentUser(playerName) ? 'You' : playerName}
</option>) </option>)

View File

@ -8,7 +8,7 @@ import { useState, useRef, useCallback, useEffect, } from "react"
- interval_stop - interval_stop
*/ */
export default function usePolling(uri, onResponce, mode = null) { export function usePolling(uri, onSuccess, mode = null) {
const [isPolling, setPolling] = useState(false); const [isPolling, setPolling] = useState(false);
const initialPollRef = useRef(true); const initialPollRef = useRef(true);
@ -17,13 +17,12 @@ export default function usePolling(uri, onResponce, mode = null) {
const intervalTimerIdRef = useRef(null); const intervalTimerIdRef = useRef(null);
const intervalTimerId = intervalTimerIdRef.current; const intervalTimerId = intervalTimerIdRef.current;
const pollData = useCallback(() => { const pollData = useCallback(() => {
setPolling(true); setPolling(true);
initialPollRef.current = false; initialPollRef.current = false;
fetch(uri) fetch(uri)
.then((responce) => { .then((response) => {
setPolling(false); setPolling(false);
if (typeof mode?.interval_sec === 'number') { if (typeof mode?.interval_sec === 'number') {
@ -31,15 +30,15 @@ export default function usePolling(uri, onResponce, mode = null) {
intervalTimerIdRef.current = setTimeout(pollData, mode.interval_sec * 1000); intervalTimerIdRef.current = setTimeout(pollData, mode.interval_sec * 1000);
} }
return responce.json(); return response.json();
}) })
.then((json) => { .then((json) => {
onResponce(json); onSuccess(json);
}) })
.catch((err) => { .catch((err) => {
console.warn(err.message); console.warn(err.message);
}) })
}, [uri, mode, onResponce, initialPollRef, intervalTimerIdRef]); }, [uri, mode, onSuccess, initialPollRef, intervalTimerIdRef]);
const stopPollInterval = useCallback(() => { const stopPollInterval = useCallback(() => {
console.log("Cancel scheduled fetch for", uri); console.log("Cancel scheduled fetch for", uri);
@ -59,3 +58,28 @@ export default function usePolling(uri, onResponce, mode = null) {
return isPolling; return isPolling;
} }
export async function doPushing(uri, method, data, { onSuccess, onPushing }) {
if (onPushing)
onPushing(true);
try {
const response = await fetch(uri, {
method,
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(data), // body data type must match "Content-Type" header
});
if (!response.ok)
throw new Error(`Error! status: ${response.status}`);
if (onSuccess)
onSuccess(await response.json());
// } catch (err) {
} finally {
if (onPushing)
onPushing(false);
}
}

View File

@ -0,0 +1,44 @@
import { useReducer } from 'react';
import { namedLocalStorage } from '../util/PersistentStorage';
import { nextState } from '../util/StateHelper';
const Persistent = (() => {
const [getOnline, setOnline] = namedLocalStorage('config.online', true);
return {
getOnline,
setOnline
}
})(); // <<--- Execute
const initialState = {
online: Persistent.getOnline() === 'true',
intervalMode
};
function dispatch(state, action) {
switch (action.type) {
case 'toggleOnline': return {
...state,
online: Persistent.setOnline(!state.online)
};
case 'next':
return nextState(state, action);
default:
throw Error('ConfigReducer: unknown action.type', action.type);
}
}
export default function useConfigReducer() {
return useReducer(dispatch, initialState);
}
function intervalMode(interval_sec) {
return (this.online === true)
? { interval_sec } // fetch from API every interval_sec
: { interval_stop: true } // user has fliped OfflineToggel
}

View File

@ -1,16 +1,20 @@
import { useReducer } from 'react'; import { useReducer } from 'react';
import { nextState } from '../util/StateHelper'; import { nextState } from '../util/StateHelper';
export const gamesInitialState = { const initialState = {
list: null, gamesList: null,
newGame: { newGame: {
whitePlayer: '', whitePlayer: '',
blackPlayer: '' blackPlayer: ''
} },
// Network
isPollingGamesList: false,
isPushingNewGame: false,
}; };
export function gamesReducer(state, action) { function reducer(state, action) {
switch (action.type) { switch (action.type) {
case 'next': case 'next':
@ -22,5 +26,5 @@ export function gamesReducer(state, action) {
} }
export default function useGamesReducer() { export default function useGamesReducer() {
return useReducer(gamesReducer, gamesInitialState); return useReducer(reducer, initialState);
} }

View File

@ -0,0 +1,24 @@
import { useReducer } from 'react';
import { nextState } from '../util/StateHelper';
const initialState = {
table: null,
// Network
isPollingTable: false
};
function reducer(state, action) {
switch (action.type) {
case 'next':
return nextState(state, action);
default:
throw Error('LeaderboardReducer: unknown action.type', action.type);
}
}
export default function useLeaderboardReducer() {
return useReducer(reducer, initialState);
}

View File

@ -1,39 +0,0 @@
import { useReducer } from 'react';
import { namedLocalStorage } from '../util/PersistentStorage';
import { nextState } from '../util/StateHelper';
const Persistent = (() => {
const [getEnabled, setEnabled] = namedLocalStorage('polling.enabled', true);
return {
getEnabled,
setEnabled
}
})(); // <<--- Execute
export const pollingInitialState = {
enabled: Persistent.getEnabled() === 'true',
games: false,
leaderboard: false
};
export function pollingReducer(curntState, action) {
switch (action.type) {
case 'toggleOnOff': return {
...curntState,
enabled: Persistent.setEnabled(!curntState.enabled)
};
case 'next':
return nextState(curntState, action);
default:
throw Error('Unknown action.type:' + action.type);
}
}
export default function usePollingReducer() {
return useReducer(pollingReducer, pollingInitialState);
}

View File

@ -1,7 +1,7 @@
import { useReducer } from 'react'; import { useReducer } from 'react';
import { localeCompare } from '../util/Locale'; import { localeCompare } from '../util/Locale';
export const userInitialState = { const initialState = {
username: '', username: '',
isCurrentUser: function (otherUsername) { isCurrentUser: function (otherUsername) {
@ -9,13 +9,13 @@ export const userInitialState = {
} }
}; };
export function userReducer(state, action) { function reducer(state, action) {
switch (action.type) { switch (action.type) {
case 'parse': case 'parse':
return { return {
...state, ...state,
username: action.json.username username: action.userJson.username
}; };
default: default:
@ -24,5 +24,5 @@ export function userReducer(state, action) {
} }
export default function useUserReducer() { export default function useUserReducer() {
return useReducer(userReducer, userInitialState); return useReducer(reducer, initialState);
} }