refactor store for router navigation

This commit is contained in:
Jeff Colombo 2019-03-20 21:06:25 -04:00
parent c6fccae553
commit ea3d340dc2
14 changed files with 210 additions and 147 deletions

File diff suppressed because one or more lines are too long

14
dist/main-bundle.js vendored

File diff suppressed because one or more lines are too long

View File

@ -4,8 +4,6 @@ import { RouteComponentProps } from 'react-router-dom';
import classNames from 'classnames';
import { League } from 'app/models/League';
import { appReducers } from 'app/index';
import * as ActionsPokemonApp from 'app/actions';
@ -13,6 +11,8 @@ import * as ActionsPokemonExplorer from 'app/components/PokemonExplorer/actions'
import * as ActionsPokemonSelectList from 'app/components/PokemonSelectList/actions';
import { IPokemonAppDispatch, IRouterProps } from 'app/types';
import { convertFormParamToPokemonForm, convertIdParamToPokemonId } from 'app/utils/navigation';
import { Footer } from 'app/components/Footer';
import { Header } from 'app/components/Header';
import { ConnectedPokemonExplorer } from 'app/components/PokemonExplorer/PokemonExplorer';
@ -53,25 +53,6 @@ class PokemonApp extends React.Component<IConnectedPokemonAppProps, IState> {
dispatch(ActionsPokemonSelectList.fetchPokemonList())
]);
dispatch(ActionsPokemonSelectList.setIsLoading(false));
const {
id,
form,
league,
} = this.props.match.params;
const pokemonId = id ? parseInt(id, 10) : null;
const pokemonForm = form ? parseInt(form, 10) : null;
if (pokemonId !== null && typeof PVPogoProtos.PokemonId[pokemonId] !== 'undefined' &&
pokemonForm !== null && typeof PVPogoProtos.PokemonForm[pokemonForm] !== 'undefined'
) {
this.handleActivatePokemon(pokemonId, pokemonForm);
}
const activeLeague = league ? parseInt(league, 10) : null;
if (activeLeague !== null && typeof League[activeLeague] !== 'undefined') {
this.handleChangeLeague(activeLeague);
}
}
public render() {
@ -81,15 +62,19 @@ class PokemonApp extends React.Component<IConnectedPokemonAppProps, IState> {
combatMoves,
} = this.props.pokemonAppState;
const {
activePokemonId,
activePokemonForm,
pokemonList,
pokemonListFiltered,
filterTerm,
} = this.props.pokemonSelectListState;
const {
leaguePokemon,
} = this.props.pokemonExplorerState;
const {
activeNavigation,
} = this.state;
const matchParams = this.props.match.params;
const activePokemonId = convertIdParamToPokemonId(matchParams.id);
const activePokemonForm = convertFormParamToPokemonForm(matchParams.form);
const wrapperCss = classNames(
styles.wrapper,
@ -117,6 +102,10 @@ class PokemonApp extends React.Component<IConnectedPokemonAppProps, IState> {
// }
);
// react optimization for rendering on pokemon switching
// https://reactjs.org/blog/2018/06/07/you-probably-dont-need-derived-state.html#recommendation-fully-uncontrolled-component-with-a-key
const uniquePokemonId = leaguePokemon !== null ? `${leaguePokemon.id}~${leaguePokemon.form}` : undefined;
return (
<div className={ wrapperCss }>
<Header>
@ -133,6 +122,7 @@ class PokemonApp extends React.Component<IConnectedPokemonAppProps, IState> {
<button className={ pokedexButtonCss } onClick={ this.handlePokedexClick }><i className={ pokedexCss } /></button>
</Header>
<ConnectedPokemonExplorer
key={ uniquePokemonId }
isOverlaid={ isInterruption }
attackTypeEffectiveness={ attackTypeEffectiveness }
combatMoves={ combatMoves }
@ -174,42 +164,26 @@ class PokemonApp extends React.Component<IConnectedPokemonAppProps, IState> {
this.handleSearchInterruption(true);
}
private readonly handleActivatePokemon = (pokemonId : PVPogoProtos.PokemonId, form : PVPogoProtos.PokemonForm) => {
private readonly handleActivatePokemon = async (pokemonId : PVPogoProtos.PokemonId, form : PVPogoProtos.PokemonForm) => {
const { dispatch } = this.props;
dispatch(ActionsPokemonSelectList.fetchPokemonLeagueStats(pokemonId, form))
.then((leaguePokemon) => {
dispatch(ActionsPokemonSelectList.setActivePokemonId(pokemonId, form));
dispatch(ActionsPokemonExplorer.setIvLevel(null));
dispatch(ActionsPokemonExplorer.setIvHp(null));
dispatch(ActionsPokemonExplorer.setIvAtk(null));
dispatch(ActionsPokemonExplorer.setIvDef(null));
dispatch(ActionsPokemonExplorer.setLeaguePokemon(leaguePokemon));
dispatch(ActionsPokemonExplorer.setSelectedCombatMoves({
quickMove: null,
chargeMove1: null,
chargeMove2: null,
}));
})
.catch((error) => {
dispatch(ActionsPokemonExplorer.setIsLoading(true));
try {
const leaguePokemon = await dispatch(ActionsPokemonApp.fetchPokemonLeagueStats(pokemonId, form));
dispatch(ActionsPokemonExplorer.reset(leaguePokemon));
} catch (error) {
// tslint:disable-next-line:no-console
console.error(error);
dispatch(ActionsPokemonExplorer.setLeaguePokemon(null));
})
.then(() => {
}
dispatch(ActionsPokemonExplorer.setIsLoading(false));
this.handleSearchInterruption(false);
});
}
private readonly handleChangeFilter = (filterTerm : string) => {
this.handleSearchInterruption(true);
return this.props.dispatch(ActionsPokemonSelectList.filterPokemonList(filterTerm));
}
private readonly handleChangeLeague = (league : League) => {
this.props.dispatch(ActionsPokemonExplorer.setActiveLeague(league));
}
}
const mapStateToProps = (state : PokemonAppProps) : PokemonAppProps => {

View File

@ -1,9 +1,12 @@
import { action } from 'typesafe-actions';
import { ILeaguePokemon } from 'app/models/League';
import { CombatMoveStats, IMaxStats } from 'app/models/Pokemon';
import * as PVPogoProtos from 'common/models/PVPogoProtos';
import { PokemonAppActionTypes, ThunkResult } from 'app/types';
import { AttackTypeEffectiveness } from 'app/models/Config';
import { CombatMoveStats, IMaxStats } from 'app/models/Pokemon';
export const setIsInterruption = (isInterruption : boolean) => action(PokemonAppActionTypes.SET_IS_INTERRUPTION, { isInterruption });
@ -13,6 +16,12 @@ export const setAttackTypeEffectiveness = (attackTypeEffectiveness : AttackTypeE
export const setCombatMoveStats = (combatMoves : CombatMoveStats) => action(PokemonAppActionTypes.SET_COMBAT_MOVE_STATS, { combatMoves });
export const setPokemonLeagueStats = (
pokemonId : PVPogoProtos.PokemonId,
form : PVPogoProtos.PokemonForm,
pokemonLeagueStats : ILeaguePokemon
) => action(PokemonAppActionTypes.SET_POKEMON_LEAGUE_STATS, { pokemonId, form, pokemonLeagueStats });
export const fetchConfig = (
) : ThunkResult<Promise<void>> => {
return async (dispatch, getState, extraArguments) => {
@ -22,3 +31,21 @@ export const fetchConfig = (
dispatch(setCombatMoveStats(config.combatMoves));
};
};
export const fetchPokemonLeagueStats = (
pokemonId : PVPogoProtos.PokemonId,
form : PVPogoProtos.PokemonForm
) : ThunkResult<Promise<ILeaguePokemon>> => {
return async (dispatch, getState, extraArguments) => {
const leagueStats = getState().pokemonAppState.pokemonLeagueStats;
// TODO: need better accessor so `~` isn't floating around all over the place, maybe make a class
const cachedLeaguePokemon = leagueStats[pokemonId + '~' + form];
if (typeof cachedLeaguePokemon !== 'undefined') {
return Promise.resolve(cachedLeaguePokemon);
}
const pokemonLeagueStats = await extraArguments.services.pokemonService.getPokemonLeagueStats(pokemonId, form);
dispatch(setPokemonLeagueStats(pokemonId, form, pokemonLeagueStats));
return pokemonLeagueStats;
};
};

View File

@ -40,7 +40,7 @@ export class Header extends React.Component<IHeaderProps> {
to="/explorer/1/0"
// style={ style }
// className={ anchorCss }
onClick={ this.reload }
// onClick={ this.reload }
>
PVPokemon
</Link>
@ -56,6 +56,4 @@ export class Header extends React.Component<IHeaderProps> {
</header>
);
}
private readonly reload = () => window.location.reload();
}

View File

@ -29,8 +29,24 @@ export const setSelectedCombatMoves = (moves : SelectedCombatMoves) => action(Po
export const setCombatMoveSelectorsOpen = (selectorsOpen : CombatMoveSelectorsOpen) => action(PokemonExplorerActionTypes.SET_COMBAT_MOVE_SELECTORS_OPEN, { selectorsOpen });
export const maximizeLevel = (
) : ThunkResult<Promise<void>> => {
export const reset = (
leaguePokemon : ILeaguePokemon | null
) : ThunkResult<Promise<void>> => {
return async (dispatch, getState, extraArguments) => {
dispatch(setIvLevel(null));
dispatch(setIvHp(null));
dispatch(setIvAtk(null));
dispatch(setIvDef(null));
dispatch(setLeaguePokemon(leaguePokemon));
dispatch(setSelectedCombatMoves({
quickMove: null,
chargeMove1: null,
chargeMove2: null,
}));
};
};
export const maximizeLevel = () : ThunkResult<Promise<void>> => {
return async (dispatch, getState, extraArguments) => {
const pokemonExplorerState = getState().pokemonExplorerState;
const {
@ -59,4 +75,4 @@ export const maximizeLevel = (
}
}
};
};
};

View File

@ -14,10 +14,6 @@ export const setPokemonList = (pokemonList : Array<IPokemon>) => action(PokemonS
export const setPokemonListFiltered = (filterTerm : string, pokemonListFiltered : Array<IPokemon>) => action(PokemonSelectListActionTypes.SET_POKEMON_LIST_FILTERED, { filterTerm, pokemonListFiltered });
export const setActivePokemonId = (activePokemonId : PVPogoProtos.PokemonId | null, activePokemonForm : PVPogoProtos.PokemonForm | null) => action(PokemonSelectListActionTypes.SET_ACTIVE_POKEMON_ID, { activePokemonId, activePokemonForm });
export const setPokemonLeagueStats = (pokemonId : PVPogoProtos.PokemonId, pokemonLeagueStats : ILeaguePokemon) => action(PokemonSelectListActionTypes.SET_POKEMON_LEAGUE_STATS, { pokemonId, pokemonLeagueStats });
export const filterPokemonList = (
filterTerm : string
) : ThunkResult<Promise<void>> => {
@ -26,6 +22,10 @@ export const filterPokemonList = (
if (filterTerm !== '') {
const pokemonList = getState().pokemonSelectListState.pokemonList;
const normalizedFilterTerm = filterTerm.toLowerCase();
// TODO: memoize the filtering, move to component instance variable?
// filter = memoize(
// (list, filterText) => list.filter(item => item.text.includes(filterText))
// );
pokemonListFiltered = pokemonList.reduce((result : Array<IPokemon>, pokemon) => {
const pokemonName = pokemon.name.toLowerCase();
const pokemonDex = '' + pokemon.dex;
@ -50,14 +50,3 @@ export const fetchPokemonList = (
dispatch(setPokemonList(pokemonList));
};
};
export const fetchPokemonLeagueStats = (
pokemonId : PVPogoProtos.PokemonId,
form : PVPogoProtos.PokemonForm
) : ThunkResult<Promise<ILeaguePokemon>> => {
return async (dispatch, getState, extraArguments) => {
const pokemonLeagueStats = await extraArguments.services.pokemonService.getPokemonLeagueStats(pokemonId, form);
dispatch(setPokemonLeagueStats(pokemonId, pokemonLeagueStats));
return pokemonLeagueStats;
};
};

View File

@ -5,12 +5,9 @@ import { IPokemonSelectListState, PokemonSelectListActionTypes } from './types';
export const initialState : IPokemonSelectListState = {
isLoading: true,
activePokemonId: null,
activePokemonForm: null,
pokemonList: [],
pokemonListFiltered: [],
filterTerm: '',
pokemonLeagueStats: {},
};
const reduceSetIsLoading = (
@ -38,26 +35,6 @@ const reduceSetPokemonListFiltered = (
pokemonListFiltered: action.payload.pokemonListFiltered,
});
const reduceSetActivePokemonId = (
state : IPokemonSelectListState,
action : ReturnType<typeof Actions.setActivePokemonId>
) : IPokemonSelectListState => ({
...state,
activePokemonId: action.payload.activePokemonId,
activePokemonForm: action.payload.activePokemonForm,
});
const reduceSetPokemonLeagueStats = (
state : IPokemonSelectListState,
action : ReturnType<typeof Actions.setPokemonLeagueStats>
) : IPokemonSelectListState => ({
...state,
pokemonLeagueStats: {
...state.pokemonLeagueStats,
[action.payload.pokemonId] : action.payload.pokemonLeagueStats,
},
});
export const PokemonSelectListReducers : Reducer<IPokemonSelectListState> = (
state : IPokemonSelectListState = initialState,
action,
@ -69,10 +46,6 @@ export const PokemonSelectListReducers : Reducer<IPokemonSelectListState> = (
return reduceSetPokemonList(state, action as ReturnType<typeof Actions.setPokemonList>);
case PokemonSelectListActionTypes.SET_POKEMON_LIST_FILTERED:
return reduceSetPokemonListFiltered(state, action as ReturnType<typeof Actions.setPokemonListFiltered>);
case PokemonSelectListActionTypes.SET_ACTIVE_POKEMON_ID:
return reduceSetActivePokemonId(state, action as ReturnType<typeof Actions.setActivePokemonId>);
case PokemonSelectListActionTypes.SET_POKEMON_LEAGUE_STATS:
return reduceSetPokemonLeagueStats(state, action as ReturnType<typeof Actions.setPokemonLeagueStats>);
default:
return state;
}

View File

@ -1,22 +1,14 @@
import { ILeaguePokemon } from 'app/models/League';
import { IPokemon } from 'app/models/Pokemon';
import * as PVPogoProtos from 'common/models/PVPogoProtos';
export interface IPokemonSelectListState {
isLoading : boolean;
activePokemonId : PVPogoProtos.PokemonId | null;
activePokemonForm : PVPogoProtos.PokemonForm | null;
pokemonList : Array<IPokemon>;
pokemonListFiltered : Array<IPokemon>;
filterTerm : string;
pokemonLeagueStats : { [id in keyof typeof PVPogoProtos.PokemonId]? : ILeaguePokemon };
}
export const PokemonSelectListActionTypes = {
SET_IS_LOADING: 'POKEMON_SELECT_LIST/SET_IS_LOADING',
SET_POKEMON_LIST: 'POKEMON_SELECT_LIST/SET_POKEMON_LIST',
SET_POKEMON_LIST_FILTERED: 'POKEMON_SELECT_LIST/SET_POKEMON_LIST_FILTERED',
SET_ACTIVE_POKEMON_ID: 'POKEMON_SELECT_LIST/SET_ACTIVE_POKEMON_ID',
SET_POKEMON_LEAGUE_STATS: 'POKEMON_SELECT_LIST/SET_POKEMON_LEAGUE_STATS',
};

View File

@ -5,7 +5,8 @@ import { BrowserRouter as Router, Redirect, Route, Switch } from 'react-router-d
import * as Redux from 'redux';
import thunk from 'redux-thunk';
import { IPokemonAppExtraArguments } from 'app/types';
import { routePokemonApp } from 'app/router';
import { IPokemonAppExtraArguments, IRouterProps } from 'app/types';
import { PokemonService } from 'api/PokemonService';
@ -13,7 +14,7 @@ import { PokemonExplorerReducers } from 'app/components/PokemonExplorer/reducers
import { PokemonSelectListReducers } from 'app/components/PokemonSelectList/reducers';
import { PokemonAppReducers } from 'app/reducers';
import { ConnectedPokemonApp } from './PokemonApp';
import { ConnectedPokemonApp } from 'app/PokemonApp';
export const appReducers = Redux.combineReducers({
pokemonAppState: PokemonAppReducers,
@ -35,11 +36,22 @@ const store = Redux.createStore(
)
);
const renderRoutePokemonApp = (props : IRouterProps) => {
routePokemonApp(props, store.dispatch);
return (
<ConnectedPokemonApp
history={ props.history }
location={ props.location }
match={ props.match }
/>
);
};
ReactDOM.render(
<Provider store={ store }>
<Router>
<Switch>
<Route path="/explorer/:id/:form" component={ ConnectedPokemonApp } />
<Route path="/explorer/:id/:form" render={ renderRoutePokemonApp } />
<Redirect from="/" to="/explorer/1/0" />
</Switch>

View File

@ -11,8 +11,9 @@ export const initialState : IPokemonAppState = {
baseDefense: 0,
level: 0,
},
attackTypeEffectiveness: new Map(),
combatMoves: new Map(),
attackTypeEffectiveness: new Map(), // TODO: READONLY after init
combatMoves: new Map(), // TODO: READONLY after init
pokemonLeagueStats: {}, // TODO: this should be a map, but only if it makes sense with action reducers
};
const reduceSetInterruption = (
@ -49,6 +50,17 @@ const reduceSetCombatMoveStats = (
combatMoves: action.payload.combatMoves,
});
const reduceSetPokemonLeagueStats = (
state : IPokemonAppState,
action : ReturnType<typeof Actions.setPokemonLeagueStats>
) : IPokemonAppState => ({
...state,
pokemonLeagueStats: {
...state.pokemonLeagueStats,
[action.payload.pokemonId + '~' + action.payload.form] : action.payload.pokemonLeagueStats,
},
});
export const PokemonAppReducers : Reducer<IPokemonAppState> = (
state : IPokemonAppState = initialState,
action,
@ -62,6 +74,8 @@ export const PokemonAppReducers : Reducer<IPokemonAppState> = (
return reduceSetAttackTypeEffectiveness(state, action as ReturnType<typeof Actions.setAttackTypeEffectiveness>);
case PokemonAppActionTypes.SET_COMBAT_MOVE_STATS:
return reduceSetCombatMoveStats(state, action as ReturnType<typeof Actions.setCombatMoveStats>);
case PokemonAppActionTypes.SET_POKEMON_LEAGUE_STATS:
return reduceSetPokemonLeagueStats(state, action as ReturnType<typeof Actions.setPokemonLeagueStats>);
default:
return state;
}

34
src/ts/app/router.ts Normal file
View File

@ -0,0 +1,34 @@
import { IPokemonAppDispatch, IRouterProps } from 'app/types';
import * as ActionsPokemonApp from 'app/actions';
import * as ActionsPokemonExplorer from 'app/components/PokemonExplorer/actions';
import { convertFormParamToPokemonForm, convertIdParamToPokemonId, convertLeagueParamToLeague } from 'app/utils/navigation';
export const routePokemonApp = async (props : IRouterProps, dispatch : IPokemonAppDispatch['dispatch']) => {
const {
id,
form,
league,
} = props.match.params;
const pokemonId = convertIdParamToPokemonId(id);
const pokemonForm = convertFormParamToPokemonForm(form);
if (pokemonId !== null && pokemonForm !== null) {
dispatch(ActionsPokemonExplorer.setIsLoading(true));
try {
const leaguePokemon = await dispatch(ActionsPokemonApp.fetchPokemonLeagueStats(pokemonId, pokemonForm));
dispatch(ActionsPokemonExplorer.reset(leaguePokemon));
} catch (error) {
// tslint:disable-next-line:no-console
console.error(error);
dispatch(ActionsPokemonExplorer.setLeaguePokemon(null));
}
dispatch(ActionsPokemonExplorer.setIsLoading(false));
}
const activeLeague = convertLeagueParamToLeague(league);
if (activeLeague !== null) {
dispatch(ActionsPokemonExplorer.setActiveLeague(activeLeague));
}
};

View File

@ -2,12 +2,13 @@ import { RouteComponentProps } from 'react-router-dom';
import { Action } from 'redux';
import { ThunkAction, ThunkDispatch } from 'redux-thunk';
import { AttackTypeEffectiveness } from 'app/models/Config';
import { ILeaguePokemon } from 'app/models/League';
import { CombatMoveStats, IMaxStats } from 'app/models/Pokemon';
import { IProviderExtraArguments } from 'common/models/IProviderExtraArguments';
import { IPokemonExplorerStore } from 'app/components/PokemonExplorer/types';
import { IPokemonSelectListState } from 'app/components/PokemonSelectList/types';
import { AttackTypeEffectiveness } from 'app/models/Config';
import { CombatMoveStats, IMaxStats } from 'app/models/Pokemon';
import { PokemonService } from 'api/PokemonService';
@ -16,6 +17,7 @@ export interface IPokemonAppState {
maxPossibleStats : IMaxStats;
attackTypeEffectiveness : AttackTypeEffectiveness;
combatMoves : CombatMoveStats;
pokemonLeagueStats : { [key : string] : ILeaguePokemon }; // TODO: map?
}
export const PokemonAppActionTypes = {
@ -23,9 +25,11 @@ export const PokemonAppActionTypes = {
SET_MAX_STATS: 'POKEMON_APP/SET_MAX_STATS',
SET_ATTACK_TYPE_EFFECTIVENESS: 'POKEMON_APP/SET_ATTACK_TYPE_EFFECTIVENESS',
SET_COMBAT_MOVE_STATS: 'POKEMON_APP/SET_COMBAT_MOVE_STATS',
SET_POKEMON_LEAGUE_STATS: 'POKEMON_SELECT_LIST/SET_POKEMON_LEAGUE_STATS',
};
export interface IPokemonAppStore extends IPokemonExplorerStore {
pokemonAppState : IPokemonAppState;
pokemonSelectListState : IPokemonSelectListState;
}

View File

@ -1,5 +1,8 @@
import { RouteComponentProps } from 'react-router-dom';
import { League } from 'app/models/League';
import * as PVPogoProtos from 'common/models/PVPogoProtos';
export const getCurrentQueryString = (location : RouteComponentProps['location'] | Window['location']) => {
const search = new URLSearchParams(location.search);
return '?' + search.toString();
@ -17,3 +20,30 @@ export const appendQueryString = (location : RouteComponentProps['location'] | W
});
return '?' + search.toString();
};
export const convertIdParamToPokemonId = (id : string) : PVPogoProtos.PokemonId | null => {
const pokemonId = id ? parseInt(id, 10) : null;
if (pokemonId !== null && typeof PVPogoProtos.PokemonId[pokemonId] !== 'undefined') {
return pokemonId;
}
return null;
};
export const convertFormParamToPokemonForm = (form : string) : PVPogoProtos.PokemonForm | null => {
const pokemonForm = form ? parseInt(form, 10) : null;
if (pokemonForm !== null && typeof PVPogoProtos.PokemonForm[pokemonForm] !== 'undefined') {
return pokemonForm;
}
return null;
};
export const convertLeagueParamToLeague = (leagueParam : string) : League | null => {
const league = leagueParam ? parseInt(leagueParam, 10) : null;
if (league !== null && typeof League[league] !== 'undefined') {
return league;
}
return null;
};