react: GlobalState: data, appCtx + polling feature

- Polling
 - time beased URI polling
 - can be diabled

- GlobaState
 - utilizes Flux pattern
 - data:
    for storing data for UI to work with
 - appData:
    for string global UI state

- major refactoring
This commit is contained in:
djmil 2023-10-18 17:20:37 +02:00
parent fa29d2a631
commit efd7127575
19 changed files with 274 additions and 102 deletions

View File

@ -16,3 +16,7 @@
.App-link {
color: #61dafb;
}
.Container {
margin-top: 25px;
}

View File

@ -1,45 +1,29 @@
import './App.css';
import React, { useState, useEffect, useCallback } from 'react';
import React from 'react'
import {
BrowserRouter,
Routes,
Route,
} from "react-router-dom";
} from "react-router-dom"
import Header from "./Header"
import Leaderboard from "./Leaderboard";
import GameProposal from "./GameProposal";
import Header from "./components/Header"
import Leaderboard from "./components/Leaderboard"
import GameProposal from "./components/GameProposal"
import DataPolling from './components/DataPolling';
//import { UserProvider } from "../contexts/UserProvider"
//import { GameProposalProvider } from './context/GameProposal';
function App() {
const [games, setGames] = useState(null);
const [polling, setPolling] = useState(false);
const pollGames = useCallback(() => {
if (polling)
return;
setPolling(true);
fetch('/api/gamestate')
.then(response => response.json())
.then(data => {
setGames(data);
setPolling(false);
})
.catch(err => console.log(err.message));
}, [polling]);
useEffect(() => {
const timer = setInterval(pollGames(), 35 * 1000); // <<-- poll new gamestates every 35 sec
return clearInterval(timer);
}, [pollGames])
return <div className="App">
<BrowserRouter>
<Header/>
<DataPolling/>
<div className="Container">
<Routes>
<Route path="/leaderboard" element={<Leaderboard/>} />
<Route path="/gameproposal" element={<GameProposal games={games}/>} />
<Route path="/gameproposal" element={<GameProposal/>} />
</Routes>
</div>
</BrowserRouter>

View File

@ -1,17 +0,0 @@
import { Link } from "react-router-dom";
import './Header.css';
export default function Header() {
return (
<p>
<h1>CordaCheckers</h1>
<nav>
<Link to="/leaderboard">Leaderboard</Link> {"| "}
<Link to="/gameproposal">Game Proposal</Link> {"| "}
<Link to="/game">Active Games</Link> {"| "}
<Link to="/archive">Archive</Link> {"| "}
<Link to="about">About</Link>
</nav>
</p>
);
}

View File

@ -0,0 +1,16 @@
import React from "react"
import { AppData } from "../../context/data"
import { AppContext } from "../../context/app"
export default function DataPolling() {
const [appData] = React.useContext(AppData)
const [appCtx, dispatchAppData] = React.useContext(AppContext)
return <div className={DataPolling.name}>
polling
<button onClick={() => dispatchAppData({type: "togglePolling"})}>
{ appCtx.disablePolling === true ? "off" : "on" }
</button>
{ appData.fetching }
</div>
}

View File

@ -6,11 +6,11 @@ export function Accept({uuid}) {
</button>
}
export function Reject({uuid}) {
return <button className="action" id={uuid} type="submit">
Reject
</button>
}
// export function Reject({uuid}) {
// return <button className="action" id={uuid} type="submit" >
// Reject
// </button>
// }
export function Cancel({uuid}) {
return <button className="action" id={uuid} type="submit">

View File

@ -0,0 +1,24 @@
import React, {useState} from 'react';
export default function Cancel({uuid}) {
const [pending, setPending] = useState(new Map())
for (const [key, value] of pending)
console.log("cancel", key, value);
function sendRequest(uuid2reject) {
const nextPending = new Map(pending)
nextPending.set(uuid2reject, null)
setPending(nextPending)
}
const status = pending.get(uuid)
const isPending = status !== undefined
return <button
className={isPending ? "visible" : "action"}
onClick={() => sendRequest(uuid) }>
Cancel
</button>
}

View File

@ -0,0 +1,21 @@
import React, {useState} from 'react';
export default function Reject({uuid}) {
const [pending, setPending] = useState([])
for (const [key, value] of Object.entries(pending))
console.log("pending ", key, value);
function sendRequest(reject_uuid) {
setPending( // Replace the old array
[ // with a new array consisting of:
...pending, // - all the old items
{ uuid: reject_uuid } // - and a new item at the end
]
)
}
return <button className="action" onClick={() => sendRequest(uuid) }>
Reject
</button>
}

View File

@ -1,24 +1,25 @@
import './index.css';
import React from 'react';
import {Accept, Reject, Cancel} from './GameProposalAction';
import './GameProposal.css';
import {Accept} from './GameProposalAction';
import Reject from './Reject'
import Cancel from './GameProposalCancel'
const State = {
WaitForOpponent: "GAME_PROPOSAL_WAIT_FOR_OPPONENT",
WaitForYou: "GAME_PROPOSAL_WAIT_FOR_YOU",
}
import { AppData } from "../../context/data"
const GameProposal = ({games}) => {
if (games == null)
return <p>Loading..</p>
export default function GameProposal() {
const [data] = React.useContext(AppData)
// for (const [key, value] of Object.entries(games))
if (data.games == null)
return <div>Loading..</div>
// for (const [key, value] of Object.entries(data.games))
// console.log(key, value);
const waitForYou = games
.filter(game => game.status === State.WaitForYou)
const waitForYou = data.games
.filter(game => game.status === Status.WaitForYou)
.map(game => {
return <div class="li" key={game.uuid}>
return <div className="li" key={game.uuid}>
<p>
You {Stone(game.myColor)} <i>vs</i> {game.opponentName} {Stone(oppositeColor(game.myColor))}
<br/>
@ -30,10 +31,10 @@ const GameProposal = ({games}) => {
</div>
});
const WaitForOpponent = games
.filter(game => game.status === State.WaitForOpponent)
const WaitForOpponent = data.games
.filter(game => game.status === Status.WaitForOpponent)
.map(game => {
return <div class="li" key={game.uuid}>
return <div className="li" key={game.uuid}>
<p>
You {Stone(game.myColor)} <i>vs</i> {game.opponentName} {Stone(oppositeColor(game.myColor))}
<br/>
@ -47,7 +48,7 @@ const GameProposal = ({games}) => {
return <div className="GameProposal">
{waitForYou}
{WaitForOpponent.length > 0 &&
<div class="separator">
<div className="separator">
waiting for opponent ({WaitForOpponent.length})
</div>
}
@ -55,14 +56,21 @@ const GameProposal = ({games}) => {
</div>
};
const Status = {
WaitForOpponent: "GAME_PROPOSAL_WAIT_FOR_OPPONENT",
WaitForYou: "GAME_PROPOSAL_WAIT_FOR_YOU",
}
function Stone(color) {
if (color === "WHITE")
return <span class="stone"></span>
return <span className="stone"></span>
if (color === "BLACK")
return <span class="stone"></span>
return <span className="stone"></span>
return <span class="stone">{color}</span>
return <span className="stone">{color}</span>
}
function oppositeColor(color) {
@ -74,5 +82,3 @@ function oppositeColor(color) {
return color
}
export default GameProposal;

View File

@ -0,0 +1,17 @@
import './index.css';
import React from "react"
import { Link } from "react-router-dom";
export default function Header() {
return <div>
<h1>CordaCheckers</h1>
<nav>
<Link to="/leaderboard">Leaderboard</Link> {"| "}
<Link to="/gameproposal">Game Proposal</Link> {"| "}
<Link to="/game">Active Games</Link> {"| "}
<Link to="/archive">Archive</Link> {"| "}
<Link to="about">About</Link>
</nav>
</div>
}

View File

@ -1,22 +1,11 @@
import React, { useState, useEffect } from 'react';
import './Leaderboard.css';
import React from "react"
import './index.css';
import { AppData } from "../../context/data"
const Leaderboard = () => {
export default function Leaderboard() {
const [data] = React.useContext(AppData)
const [data, setData] = useState(null);
useEffect(() => {
fetch('/api/leaderboard')
.then((response) => response.json())
.then((data) => {
setData(data);
})
.catch((err) => {
console.log(err.message);
});
}, []);
if (data == null)
if (data.leaderboard == null)
return <p>Loading...</p>
// var listItems = Object.keys(data).map(playerName => {
@ -28,8 +17,8 @@ const Leaderboard = () => {
// });
// return <ul>{listItems}</ul>;
const tableRows = Object.keys(data).map(playerName => {
var rank = data[playerName];
const tableRows = Object.keys(data.leaderboard).map(playerName => {
var rank = data.leaderboard[playerName];
return <tr key={playerName}>
<td>{playerName}</td>
@ -55,5 +44,3 @@ const Leaderboard = () => {
</table>
</div>
};
export default Leaderboard;

View File

@ -0,0 +1,17 @@
import React from "react"
import { reducer, initialState } from "./reducer"
export const AppContext = React.createContext({
state: initialState,
dispatch: () => null
})
export const AppContextProvider = ({ children }) => {
const [state, dispatch] = React.useReducer(reducer, initialState)
return (
<AppContext.Provider value={[ state, dispatch ]}>
{ children }
</AppContext.Provider>
)
}

View File

@ -0,0 +1,16 @@
export const reducer = (state, action) => {
switch (action.type) {
case "togglePolling":
return { ...state,
disablePolling: !state.disablePolling // on/off
}
default:
return state
}
}
export const initialState = {
disablePolling: false,
}

View File

@ -0,0 +1,40 @@
import { useState, useCallback, useEffect, } from "react"
export default function Poll(url, interval_sec, disabled) {
const [cache, setCache] = useState(null)
const [fetching, setFetching] = useState(false)
const [timeoutID, setTimeoutID] = useState(null)
const fecthData = useCallback(() => {
setTimeoutID(null)
setFetching(true)
fetch(url)
.then((response) => {
setFetching(false)
return response.json()
})
.then((freshData) => setCache(freshData))
.catch((err) => console.log(err.message))
}, [url])
useEffect(() => {
if (cache == null) {
fecthData() // <<-- run immediatly on startup
}
else if (disabled === true) {
clearTimeout(timeoutID) // cancel already scheduled fetch
setTimeoutID(null)
}
else if (timeoutID === null) {
const timeoutID = setTimeout(fecthData, interval_sec * 1000)
setTimeoutID(timeoutID)
console.log("Fetch '" +url +"' scheduled in " +interval_sec +" sec")
}
}, [url, cache, fecthData, timeoutID, disabled, interval_sec]);
return {
data: cache,
fetching
}
}

View File

@ -0,0 +1,36 @@
import React from "react"
import { reducer, initialState } from "./reducer"
import { AppContext } from "../app"
import Poll from "./Poll"
export const AppData = React.createContext({
state: initialState,
dispatch: () => null
})
export const AppDataProvider = ({ children }) => {
const [data, dispatchData] = React.useReducer(reducer, initialState)
const [appContext] = React.useContext(AppContext)
const games = Poll('api/gamestate', 30, appContext.disablePolling)
const leaderboard = Poll('api/leaderboard', 60, appContext.disablePolling)
data.games = games.data
data.leaderboard = leaderboard.data
var fetching = []
if (games.fetching === true)
fetching = [...fetching, "games"]
if (leaderboard.fetching === true)
fetching = [...fetching, "leaderboard"]
data.fetching = fetching
return (
<AppData.Provider value={[data, dispatchData]}>
{children}
</AppData.Provider>
)
}

View File

@ -0,0 +1,14 @@
export const reducer = (state, action) => {
switch (action.type) {
default:
console.warn("Unknown action.type", action)
return state
}
}
export const initialState = {
games: null,
leaderboard: null,
fetching: []
}

View File

@ -1,14 +1,21 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import App from './App';
import reportWebVitals from './reportWebVitals';
import App from './App';
import { AppDataProvider } from "./context/data"
import { AppContextProvider } from "./context/app"
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
<React.StrictMode>
//<React.StrictMode>
<AppContextProvider>
<AppDataProvider>
<App />
</React.StrictMode>
</AppDataProvider>
</AppContextProvider>
//</React.StrictMode>
);
// If you want to start measuring performance in your app, pass a function