matteo-the-prestige/simmadome/src/CreateLeague.tsx
2021-01-16 01:30:15 -05:00

527 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, {useState, useRef, useLayoutEffect, useReducer} from 'react';
import {removeIndex, replaceIndex, append, arrayOf, shallowClone, getUID, DistributiveOmit} from './util';
import './CreateLeague.css';
import twemoji from 'twemoji';
// CONSTS
const MAX_SUBLEAGUE_DIVISION_TOTAL = 22;
const MAX_TEAMS_PER_DIVISION = 12;
// STATE CLASSES
class LeagueStructureState {
subleagues: SubleagueState[]
constructor(subleagues: SubleagueState[] = []) {
this.subleagues = subleagues;
}
}
class SubleagueState {
name: string
divisions: DivisionState[]
id: string|number
constructor(divisions: DivisionState[] = []) {
this.name = "";
this.divisions = divisions;
this.id = getUID();
}
}
class DivisionState {
name: string
teams: TeamState[]
id: string|number
constructor() {
this.name = "";
this.teams = [];
this.id = getUID();
}
}
class TeamState {
name: string
id: string|number
constructor(name: string = "") {
this.name = name;
this.id = getUID();
}
}
// STRUCTURE REDUCER
type StructureReducerActions =
{type: 'remove_subleague', subleague_index: number} |
{type: 'add_subleague'} |
{type: 'rename_subleague', subleague_index: number, name: string} |
{type: 'remove_divisions', division_index: number} |
{type: 'add_divisions'} |
{type: 'rename_division', subleague_index: number, division_index: number, name: string} |
{type: 'remove_team', subleague_index: number, division_index: number, name:string} |
{type: 'add_team', subleague_index:number, division_index:number, name:string}
function leagueStructureReducer(state: LeagueStructureState, action: StructureReducerActions): LeagueStructureState {
switch (action.type) {
case 'remove_subleague':
return {subleagues: removeIndex(state.subleagues, action.subleague_index)};
case 'add_subleague':
return {subleagues: append(state.subleagues, new SubleagueState(
arrayOf(state.subleagues[0].divisions.length, i =>
new DivisionState()
)
))}
case 'rename_subleague':
return replaceSubleague(state, action.subleague_index, subleague => {
let nSubleague = shallowClone(subleague);
nSubleague.name = action.name;
return nSubleague;
});
case 'remove_divisions':
return {subleagues: state.subleagues.map(subleague => {
let nSubleague = shallowClone(subleague);
nSubleague.divisions = removeIndex(subleague.divisions, action.division_index)
return nSubleague;
})};
case 'add_divisions':
return {subleagues: state.subleagues.map(subleague => {
let nSubleague = shallowClone(subleague);
nSubleague.divisions = append(subleague.divisions, new DivisionState())
return nSubleague;
})};
case 'rename_division':
return replaceDivision(state, action.subleague_index, action.division_index, division => {
let nDivision = shallowClone(division);
nDivision.name = action.name;
return nDivision;
});
case 'remove_team':
return replaceDivision(state, action.subleague_index, action.division_index, division => {
let nDivision = shallowClone(division);
nDivision.teams = removeIndex(division.teams, division.teams.findIndex(val => val.name === action.name));
return nDivision;
});
case 'add_team':
return replaceDivision(state, action.subleague_index, action.division_index, division => {
let nDivision = shallowClone(division);
nDivision.teams = append(division.teams, new TeamState(action.name));
return nDivision;
});
}
}
function replaceSubleague(state: LeagueStructureState, si: number, func: (val: SubleagueState) => SubleagueState) {
return {subleagues: replaceIndex(state.subleagues, si, func(state.subleagues[si]))}
}
function replaceDivision(state: LeagueStructureState, si: number, di: number, func:(val: DivisionState) => DivisionState) {
return replaceSubleague(state, si, subleague => {
let nSubleague = shallowClone(subleague);
nSubleague.divisions = replaceIndex(subleague.divisions, di, func(subleague.divisions[di]));
return nSubleague;
});
}
// OPTIONS REDUCER
class LeagueOptionsState {
games_series = "3"
intra_division_series = "8"
inter_division_series = "16"
inter_league_series = "8"
top_postseason = "1"
wildcards = "0"
}
type OptionsReducerActions =
{type: 'set_games_series', value: string} |
{type: 'set_intra_division_series', value: string} |
{type: 'set_inter_division_series', value: string} |
{type: 'set_inter_league_series', value: string} |
{type: 'set_top_postseason', value: string} |
{type: 'set_wildcards', value: string}
function LeagueOptionsReducer(state: LeagueOptionsState, action: OptionsReducerActions) {
let newState = shallowClone(state);
switch (action.type) {
case 'set_games_series':
newState.games_series = action.value;
break;
case 'set_intra_division_series':
newState.intra_division_series = action.value;
break;
case 'set_inter_division_series':
newState.inter_division_series = action.value;
break;
case 'set_inter_league_series':
newState.inter_league_series = action.value;
break;
case 'set_top_postseason':
newState.top_postseason = action.value;
break;
case 'set_wildcards':
newState.wildcards = action.value;
break;
}
return newState
}
// CREATE LEAGUE
let initLeagueStructure = {
subleagues: [0, 1].map((val) =>
new SubleagueState([0, 1].map((val) =>
new DivisionState()
))
)
};
function CreateLeague() {
let [name, setName] = useState("");
let [showError, setShowError] = useState(false);
let [nameExists, setNameExists] = useState(false);
let [deletedTeams, setDeletedTeams] = useState<string[]>([]);
let [createSuccess, setCreateSuccess] = useState(false);
let [structure, structureDispatch] = useReducer(leagueStructureReducer, initLeagueStructure);
let [options, optionsDispatch] = useReducer(LeagueOptionsReducer, new LeagueOptionsState());
let self = useRef<HTMLDivElement | null>(null)
useLayoutEffect(() => {
if (self.current) {
twemoji.parse(self.current)
}
})
if (createSuccess) {
return(
<div className="cl_league_main" ref={self}>
<div className="cl_confirm_box">
League created succesfully!
</div>
</div>
);
}
return (
<div className="cl_league_main" ref={self}>
<input type="text" className="cl_league_name" placeholder="League Name" value={name} onChange={(e) => {
setName(e.target.value);
setNameExists(false);
}}/>
<div className="cl_structure_err">{
name === "" && showError ? "A name is required." :
nameExists && showError ? "A league by that name already exists" :
""
}</div>
<LeagueStructre state={structure} dispatch={structureDispatch} deletedTeams={deletedTeams} showError={showError}/>
<div className="cl_league_options">
<LeagueOptions state={options} dispatch={optionsDispatch} showError={showError}/>
<div className="cl_option_submit_box">
<button className="cl_option_submit" onClick={e => {
if (!validRequest(name, structure, options)) {
setShowError(true);
} else {
let req = new XMLHttpRequest();
let data = makeRequest(name, structure, options);
req.open("POST", "/api/leagues", true);
req.setRequestHeader("Content-type", "application/json");
req.onreadystatechange = () => {
if(req.readyState === 4) {
if (req.status === 200) {
setCreateSuccess(true);
}
if (req.status === 400) {
let err = JSON.parse(req.response);
switch (err.status) {
case 'err_league_exists':
setNameExists(true);
break;
case 'err_no_such_team':
setDeletedTeams(err.cause);
break;
}
setShowError(true);
}
}
}
req.send(data);
}
}}>Submit</button>
<div className="cl_option_err">{
!validRequest(name, structure, options) && showError ?
"Cannot create league. Some information is missing or invalid." : ""
}</div>
</div>
</div>
</div>
);
}
function makeRequest(name:string, structure: LeagueStructureState, options:LeagueOptionsState) {
return JSON.stringify({
name: name,
structure: {
subleagues: structure.subleagues.map(subleague => ({
name: subleague.name,
divisions: subleague.divisions.map(division => ({
name: division.name,
teams: division.teams.map(team => team.name)
}))
}))
},
games_per_series: Number(options.games_series),
division_series: Number(options.intra_division_series),
inter_division_series: Number(options.inter_division_series),
inter_league_series: Number(options.inter_league_series),
top_postseason: Number(options.top_postseason),
wildcards: Number(options.wildcards)
});
}
function validRequest(name:string, structure: LeagueStructureState, options:LeagueOptionsState) {
return (
name !== "" &&
validNumber(options.games_series) &&
validNumber(options.intra_division_series) &&
validNumber(options.inter_division_series) &&
validNumber(options.inter_league_series) &&
validNumber(options.top_postseason) &&
validNumber(options.wildcards, 0) &&
structure.subleagues.length % 2 === 0 &&
structure.subleagues.every((subleague, si) =>
subleague.name !== "" &&
!structure.subleagues.slice(0, si).some(val => val.name === subleague.name) &&
subleague.divisions.every((division, di) =>
division.name !== "" &&
division.teams.length >= 2 &&
division.teams.length <= MAX_TEAMS_PER_DIVISION &&
!subleague.divisions.slice(0, di).some(val => val.name === division.name)
)
)
)
}
function validNumber(value: string, min = 1) {
return !isNaN(Number(value)) && Number(value) >= min;
}
// LEAGUE STRUCUTRE
function LeagueStructre(props: {state: LeagueStructureState, dispatch: React.Dispatch<StructureReducerActions>, deletedTeams: string[], showError: boolean}) {
let nSubleagues = props.state.subleagues.length;
let nDivisions = props.state.subleagues[0].divisions.length;
return (
<div className="cl_league_structure">
<div className="cl_league_structure_scrollbox">
<div className="cl_subleague_add_align">
<div className="cl_league_structure_table">
<SubleagueHeaders subleagues={props.state.subleagues} dispatch={props.dispatch} showError={props.showError}/>
<Divisions subleagues={props.state.subleagues} dispatch={props.dispatch} deletedTeams={props.deletedTeams} showError={props.showError}/>
</div>
{ (nSubleagues+1) * (nDivisions+1) < MAX_SUBLEAGUE_DIVISION_TOTAL ?
<button className="cl_subleague_add" onClick={e => props.dispatch({type: 'add_subleague'})}></button> :
<div className="cl_subleague_add_filler"/>
}
</div>
</div>
<div className="cl_structure_err">{props.state.subleagues.length % 2 !== 0 && props.showError ? "Must have an even number of subleagues." : ""}</div>
{ nSubleagues * (nDivisions+2) < MAX_SUBLEAGUE_DIVISION_TOTAL ?
<button className="cl_division_add" onClick={e => props.dispatch({type: 'add_divisions'})}></button>:
<div className="cl_division_add_filler"/>
}
</div>
);
}
function SubleagueHeaders(props: {subleagues: SubleagueState[], dispatch: React.Dispatch<StructureReducerActions>, showError:boolean}) {
return (
<div className="cl_headers">
<div key="filler" className="cl_delete_filler"/>
{props.subleagues.map((subleague, i) => {
let err =
subleague.name === "" ?
"A name is required." :
props.subleagues.slice(0, i).some(val => val.name === subleague.name) ?
"Each subleague must have a different name." :
"";
return (
<div key={subleague.id} className="cl_table_header">
<div className="cl_subleague_bg">
<SubleageHeader state={subleague} canDelete={props.subleagues.length > 1} dispatch={action =>
props.dispatch(Object.assign({subleague_index: i}, action))
}/>
<div className="cl_structure_err">{props.showError ? err : ""}</div>
</div>
</div>
)
})}
</div>
);
}
function SubleageHeader(props: {state: SubleagueState, canDelete: boolean, dispatch:(action: DistributiveOmit<StructureReducerActions, 'subleague_index'>) => void}) {
return (
<div className="cl_subleague_header">
<input type="text" className="cl_subleague_name" placeholder="Subleague Name" value={props.state.name} onChange={e =>
props.dispatch({type: 'rename_subleague', name: e.target.value})
}/>
{props.canDelete ? <button className="cl_subleague_delete" onClick={e => props.dispatch({type: 'remove_subleague'})}></button> : null}
</div>
);
}
function Divisions(props: {subleagues: SubleagueState[], dispatch: React.Dispatch<StructureReducerActions>, deletedTeams: string[], showError: boolean}) {
return (<>
{props.subleagues[0].divisions.map((val, di) => (
<div key={val.id} className="cl_table_row">
<div key="delete" className="cl_delete_box">
{props.subleagues[0].divisions.length > 1 ?
<button className="cl_division_delete" onClick={e => props.dispatch({type: 'remove_divisions', division_index: di})}></button> :
null
}
</div>
{props.subleagues.map((subleague, si) => (
<div key={subleague.id} className="cl_division_cell">
<div className="cl_subleague_bg">
<Division state={subleague.divisions[di]} dispatch={action =>
props.dispatch(Object.assign({subleague_index: si, division_index: di}, action))
}
isDuplicate={subleague.divisions.slice(0, di).some(val => val.name === subleague.divisions[di].name)}
deletedTeams={props.deletedTeams} showError={props.showError} />
</div>
</div>
))}
</div>
))}
</>);
}
function Division(props: {
state: DivisionState,
dispatch: (action: DistributiveOmit<StructureReducerActions, 'subleague_index'|'division_index'>) => void,
isDuplicate: boolean,
deletedTeams: string[],
showError: boolean
}) {
let [newName, setNewName] = useState("");
let [searchResults, setSearchResults] = useState<string[]>([]);
let newNameInput = useRef<HTMLInputElement>(null);
let resultList = useRef<HTMLDivElement>(null);
useLayoutEffect(() => {
if (resultList.current) {
twemoji.parse(resultList.current)
}
})
let divisionErr =
props.state.name === "" ?
"A name is required." :
props.isDuplicate ?
"Each division in a subleague must have a different name." :
""
let teamsErr = props.state.teams.length < 2 ? "Must have at least 2 teams." : "";
return (
<div className="cl_division">
<div className="cl_division_name_box">
<input type="text" className="cl_division_name" placeholder="Division Name" key="input" value={props.state.name} onChange={e =>
props.dispatch({type: 'rename_division', name: e.target.value})
}/>
<div className="cl_structure_err cl_structure_err_div">{props.showError ? divisionErr : ""}</div>
</div>
{props.state.teams.map((team, i) => {
let showDeleted = props.showError && props.deletedTeams.includes(team.name)
return (<>
<div className="cl_team" key={team.id}>
<div className={"cl_team_name" + (showDeleted ? " cl_team_name_err" : "")}>{team.name}</div>
<button className="cl_team_delete" onClick={e => props.dispatch({type:'remove_team', name: team.name})}></button>
</div>
<div className="cl_structure_err cl_structure_err_team">{showDeleted ? "This team was deleted" : ""}</div>
</>)
})}
{ props.state.teams.length < MAX_TEAMS_PER_DIVISION ? <>
<div className="cl_team_add">
<input type="text" className="cl_newteam_name" placeholder="Add team..." value={newName} ref={newNameInput}
onChange={e => {
let params = new URLSearchParams({query: e.target.value, page_len: '5', page_num: '0'});
fetch("/api/teams/search?" + params.toString())
.then(response => response.json())
.then(data => setSearchResults(data));
setNewName(e.target.value);
}}/>
</div>
{searchResults.length > 0 && newName.length > 0 ?
(<div className="cl_search_list" ref={resultList}>
{searchResults.map(result =>
<div className="cl_search_result" key={result} onClick={e => {
props.dispatch({type:'add_team', name: result});
setNewName("");
if (newNameInput.current) {
newNameInput.current.focus();
}
}}>{result}</div>
)}
</div>):
null
}</> :
null
}
<div className="cl_structure_err cl_structure_err_teams">{props.showError ? teamsErr : ""}</div>
</div>
);
}
// LEAGUE OPTIONS
function LeagueOptions(props: {state: LeagueOptionsState, dispatch: React.Dispatch<OptionsReducerActions>, showError: boolean}) {
return (
<div className="cl_option_main">
<div className="cl_option_column">
<NumberInput title="Number of games per series" value={props.state.games_series} setValue={(value: string) =>
props.dispatch({type: 'set_games_series', value: value})} showError={props.showError}/>
<NumberInput title="Number of teams from top of division to postseason" value={props.state.top_postseason} setValue={(value: string) =>
props.dispatch({type: 'set_top_postseason', value: value})} showError={props.showError}/>
<NumberInput title="Number of wildcards" value={props.state.wildcards} minValue={0} setValue={(value: string) =>
props.dispatch({type: 'set_wildcards', value: value})} showError={props.showError}/>
</div>
<div className="cl_option_column">
<NumberInput title="Number of series with each division opponent" value={props.state.intra_division_series} setValue={(value: string) =>
props.dispatch({type: 'set_intra_division_series', value: value})} showError={props.showError}/>
<NumberInput title="Number of inter-divisional series" value={props.state.inter_division_series} setValue={(value: string) =>
props.dispatch({type: 'set_inter_division_series', value: value})} showError={props.showError}/>
<NumberInput title="Number of inter-league series" value={props.state.inter_league_series} setValue={(value: string) =>
props.dispatch({type: 'set_inter_league_series', value: value})} showError={props.showError}/>
</div>
</div>
);
}
function NumberInput(props: {title: string, value: string, setValue: (newVal: string) => void, showError: boolean, minValue? : number}) {
let minValue = 1;
if (props.minValue !== undefined) {
minValue = props.minValue
}
return (
<div className="cl_option_box">
<div className="cl_option_label">{props.title}</div>
<input className="cl_option_input" type="number" min={minValue} value={props.value} onChange={e => props.setValue(e.target.value)}/>
<div className="cl_option_err">{(isNaN(Number(props.value)) || Number(props.value) < minValue) && props.showError ? "Must be a number greater than " + minValue : ""}</div>
</div>
);
}
export default CreateLeague;