add search filter to pokemon list

This commit is contained in:
Jeff Colombo 2019-02-02 01:35:09 -05:00
parent 83bf78fee4
commit 44cf0e349c
10 changed files with 191 additions and 52 deletions

BIN
src/img/missingno.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@ -1,5 +1,17 @@
$scale: 4; $scale: 4;
.pokemon-missing-no {
display: inline-block;
background: url('../img/missingno.png') no-repeat;
width: 84px;
height: 195px;
image-rendering: -webkit-crisp-edges;
image-rendering: -moz-crisp-edges;
image-rendering: crisp-edges;
image-rendering: pixelated;
-ms-interpolation-mode: nearest-neighbor;
}
// generated by https://css.spritegen.com/ // generated by https://css.spritegen.com/
// sprites from https://pokemondb.net/sprites // sprites from https://pokemondb.net/sprites
// alolan sprites from https://www.pokecommunity.com/showthread.php?t=368703 // alolan sprites from https://www.pokecommunity.com/showthread.php?t=368703

View File

@ -32,8 +32,10 @@ class PokemonApp extends React.Component<IConnectedPokemonAppProps> {
public render() { public render() {
const { const {
activePokemonIndex, activePokemonId,
pokemonList, pokemonList,
pokemonListFiltered,
filterTerm,
} = this.props.pokemonSelectListState; } = this.props.pokemonSelectListState;
const { const {
individualValues, individualValues,
@ -44,9 +46,11 @@ class PokemonApp extends React.Component<IConnectedPokemonAppProps> {
<div className={ styles.wrapper }> <div className={ styles.wrapper }>
<PokemonSelectList <PokemonSelectList
isLoading={ this.props.pokemonSelectListState.isLoading } isLoading={ this.props.pokemonSelectListState.isLoading }
activePokemonIndex={ activePokemonIndex } activePokemonId={ activePokemonId }
pokemonList={ pokemonList } pokemonList={ filterTerm === '' ? pokemonList : pokemonListFiltered }
filterTerm={ this.props.pokemonSelectListState.filterTerm }
handleActivatePokemon={ this.handleActivatePokemon } handleActivatePokemon={ this.handleActivatePokemon }
handleChangeFilter={ this.handleChangeFilter }
/> />
{ leaguePokemon !== null && { leaguePokemon !== null &&
<PokemonExplorer <PokemonExplorer
@ -60,13 +64,12 @@ class PokemonApp extends React.Component<IConnectedPokemonAppProps> {
); );
} }
private readonly handleActivatePokemon = (pokemonIndex : number) => { private readonly handleActivatePokemon = (pokemonId : string) => {
const { dispatch, pokemonSelectListState } = this.props; const { dispatch } = this.props;
const pokemonId = pokemonSelectListState.pokemonList[pokemonIndex].id;
dispatch(ActionsPokemonSelectList.fetchPokemonLeagueStats(pokemonId)) dispatch(ActionsPokemonSelectList.fetchPokemonLeagueStats(pokemonId))
.then((leaguePokemon) => { .then((leaguePokemon) => {
dispatch(ActionsPokemonSelectList.setActivePokemonIndex(pokemonIndex)); dispatch(ActionsPokemonSelectList.setActivePokemonId(pokemonId));
dispatch(ActionsPokemonExplorer.setIvLevel(null)); dispatch(ActionsPokemonExplorer.setIvLevel(null));
dispatch(ActionsPokemonExplorer.setIvHp(null)); dispatch(ActionsPokemonExplorer.setIvHp(null));
dispatch(ActionsPokemonExplorer.setIvAtk(null)); dispatch(ActionsPokemonExplorer.setIvAtk(null));
@ -81,6 +84,10 @@ class PokemonApp extends React.Component<IConnectedPokemonAppProps> {
.then(() => dispatch(ActionsPokemonExplorer.setIsLoading(false))); .then(() => dispatch(ActionsPokemonExplorer.setIsLoading(false)));
} }
private readonly handleChangeFilter = (filterTerm : string) => {
return this.props.dispatch(ActionsPokemonSelectList.filterPokemonList(filterTerm));
}
private readonly handleChangeIndividualValue = (stat : IndividualValueKey, value : number | null) => { private readonly handleChangeIndividualValue = (stat : IndividualValueKey, value : number | null) => {
const { dispatch } = this.props; const { dispatch } = this.props;

View File

@ -12,10 +12,12 @@ import * as styles from './styles/PokemonSelectList.scss';
export interface IPokemonSelectListProps { export interface IPokemonSelectListProps {
isLoading : boolean; isLoading : boolean;
activePokemonIndex : number | null; activePokemonId : string | null;
pokemonList : Array<IPokemon>; pokemonList : Array<IPokemon>;
filterTerm : string;
handleActivatePokemon : (index : number) => void; handleActivatePokemon : (pokemonId : string) => void;
handleChangeFilter : (filterTerm : string) => Promise<void>;
} }
interface IState { interface IState {
@ -31,6 +33,7 @@ interface IRowFactory {
} }
export class PokemonSelectList extends React.Component<IPokemonSelectListProps, IState> { export class PokemonSelectList extends React.Component<IPokemonSelectListProps, IState> {
private listRef : VariableSizeList | null = null;
constructor(props : IPokemonSelectListProps) { constructor(props : IPokemonSelectListProps) {
super(props); super(props);
@ -45,21 +48,43 @@ export class PokemonSelectList extends React.Component<IPokemonSelectListProps,
public render() { public render() {
const { width, height } = this.state.dimensions; const { width, height } = this.state.dimensions;
const listLength = this.props.pokemonList.length;
const onResize = (contentRect : ContentRect) => { const onResize = (contentRect : ContentRect) => {
if (typeof contentRect.bounds !== 'undefined') { if (typeof contentRect.bounds !== 'undefined') {
this.setState({ dimensions: contentRect.bounds }); this.setState({ dimensions: contentRect.bounds });
} }
}; };
const classes = classNames( const wrapperCss = classNames(
'nes-container',
styles.leftPanel, styles.leftPanel,
{ {
loading: this.props.isLoading, loading: this.props.isLoading,
} }
); );
const listWrapperCss = classNames(
'nes-container',
styles.listWrapper,
{
[ styles.emptyList ]: listLength === 0
}
);
const inputTextCss = classNames(
'nes-input',
styles.filterInput
);
return ( return (
<div id="pokemon-select-list" className={ classes }> <div id="pokemon-select-list" className={ wrapperCss }>
<input
name="filter"
type="text"
className={ inputTextCss }
onChange={ this.handleChangeFilter }
value={ this.props.filterTerm }
placeholder="Pokemon Name"
/>
<div className={ listWrapperCss }>
{ listLength > 0 &&
<Measure <Measure
bounds={ true } bounds={ true }
onResize={ onResize } onResize={ onResize }
@ -68,8 +93,10 @@ export class PokemonSelectList extends React.Component<IPokemonSelectListProps,
({ measureRef }) => ( ({ measureRef }) => (
<div ref={ measureRef }> <div ref={ measureRef }>
<VariableSizeList <VariableSizeList
ref={ this.setListRef }
height={ height } height={ height }
itemCount={ this.props.pokemonList.length } itemKey={ this.getListItemKey }
itemCount={ listLength }
estimatedItemSize={ 25 } estimatedItemSize={ 25 }
itemSize={ this.calculateRowHeight } itemSize={ this.calculateRowHeight }
width={ width } width={ width }
@ -80,11 +107,27 @@ export class PokemonSelectList extends React.Component<IPokemonSelectListProps,
) )
} }
</Measure> </Measure>
}
{ listLength === 0 &&
<div className={ styles.emptyState }>
<i className="pokemon-missing-no" />
<h3>MissingNo.</h3>
</div>
}
</div>
</div> </div>
); );
} }
private readonly calculateRowHeight = (index : number) => this.props.pokemonList[index].form !== null ? 40 : 25; private readonly setListRef = (element : VariableSizeList) => this.listRef = element;
private readonly getListItemKey = (index : number) => {
return index + this.props.pokemonList[index].id;
}
private readonly calculateRowHeight = (index : number) => {
return this.props.pokemonList[index].form !== null ? 40 : 25;
}
private rowFactory({ index, style } : IRowFactory) { private rowFactory({ index, style } : IRowFactory) {
const pokemon = this.props.pokemonList[index]; const pokemon = this.props.pokemonList[index];
@ -92,7 +135,7 @@ export class PokemonSelectList extends React.Component<IPokemonSelectListProps,
const anchorCss = classNames( const anchorCss = classNames(
'list-item', 'list-item',
{ {
active: this.props.activePokemonIndex === index active: this.props.activePokemonId === pokemon.id
} }
); );
const dexCss = classNames( const dexCss = classNames(
@ -103,10 +146,10 @@ export class PokemonSelectList extends React.Component<IPokemonSelectListProps,
'de-emphasize', 'de-emphasize',
styles.form styles.form
); );
const onClick = () => this.props.handleActivatePokemon(index); const onClick = () => this.props.handleActivatePokemon(pokemon.id);
return ( return (
<a <a
key={ index } key={ index + pokemon.id }
style={ style } style={ style }
className={ anchorCss } className={ anchorCss }
onClick={ onClick } onClick={ onClick }
@ -119,4 +162,13 @@ export class PokemonSelectList extends React.Component<IPokemonSelectListProps,
</a> </a>
); );
} }
private readonly handleChangeFilter = (event : React.ChangeEvent<HTMLInputElement>) => {
this.props.handleChangeFilter(event.currentTarget.value)
.then(() => {
if (this.listRef !== null) {
this.listRef.resetAfterIndex(0, true);
}
});
}
} }

View File

@ -9,10 +9,37 @@ export const setIsLoading = (isLoading : boolean) => action(PokemonSelectListAct
export const setPokemonList = (pokemonList : Array<IPokemon>) => action(PokemonSelectListActionTypes.SET_POKEMON_LIST, { pokemonList }); export const setPokemonList = (pokemonList : Array<IPokemon>) => action(PokemonSelectListActionTypes.SET_POKEMON_LIST, { pokemonList });
export const setActivePokemonIndex = (activePokemonIndex : number | null) => action(PokemonSelectListActionTypes.SET_ACTIVE_POKEMON_INDEX, { activePokemonIndex }); export const setPokemonListFiltered = (filterTerm : string, pokemonListFiltered : Array<IPokemon>) => action(PokemonSelectListActionTypes.SET_POKEMON_LIST_FILTERED, { filterTerm, pokemonListFiltered });
export const setActivePokemonId = (activePokemonId : string | null) => action(PokemonSelectListActionTypes.SET_ACTIVE_POKEMON_ID, { activePokemonId });
export const setPokemonLeagueStats = (pokemonId : string, pokemonLeagueStats : ILeaguePokemon) => action(PokemonSelectListActionTypes.SET_POKEMON_LEAGUE_STATS, { pokemonId, pokemonLeagueStats }); export const setPokemonLeagueStats = (pokemonId : string, pokemonLeagueStats : ILeaguePokemon) => action(PokemonSelectListActionTypes.SET_POKEMON_LEAGUE_STATS, { pokemonId, pokemonLeagueStats });
export const filterPokemonList = (
filterTerm : string
) : ThunkResult<Promise<void>> => {
return async (dispatch, getState, extraArguments) => {
let pokemonListFiltered : Array<IPokemon> = [];
if (filterTerm !== '') {
const pokemonList = getState().pokemonSelectListState.pokemonList;
const normalizedFilterTerm = filterTerm.toLowerCase();
pokemonListFiltered = pokemonList.reduce((result : Array<IPokemon>, pokemon) => {
const pokemonName = pokemon.name.toLowerCase();
const pokemonDex = '' + pokemon.dex;
const pokemonForm = (pokemon.form || '').toLowerCase();
if (pokemonName.indexOf(normalizedFilterTerm) === 0 ||
pokemonDex.indexOf(normalizedFilterTerm) === 0 ||
normalizedFilterTerm === pokemonForm
) {
result.push(pokemon);
}
return result;
}, []);
}
dispatch(setPokemonListFiltered(filterTerm, pokemonListFiltered));
};
};
export const fetchPokemonList = ( export const fetchPokemonList = (
) : ThunkResult<Promise<void>> => { ) : ThunkResult<Promise<void>> => {
return async (dispatch, getState, extraArguments) => { return async (dispatch, getState, extraArguments) => {

View File

@ -5,10 +5,11 @@ import { IPokemonSelectListState, PokemonSelectListActionTypes } from './types';
export const initialState : IPokemonSelectListState = { export const initialState : IPokemonSelectListState = {
isLoading: true, isLoading: true,
activePokemonIndex: null, activePokemonId: null,
pokemonList: [], pokemonList: [],
pokemonListFiltered: [], pokemonListFiltered: [],
pokemonLeagueStats: {} filterTerm: '',
pokemonLeagueStats: {},
}; };
const reduceSetIsLoading = ( const reduceSetIsLoading = (
@ -27,12 +28,21 @@ const reduceSetPokemonList = (
pokemonList: action.payload.pokemonList, pokemonList: action.payload.pokemonList,
}); });
const reduceSetActivePokemonIndex = ( const reduceSetPokemonListFiltered = (
state : IPokemonSelectListState, state : IPokemonSelectListState,
action : ReturnType<typeof Actions.setActivePokemonIndex> action : ReturnType<typeof Actions.setPokemonListFiltered>
) : IPokemonSelectListState => ({ ) : IPokemonSelectListState => ({
...state, ...state,
activePokemonIndex: action.payload.activePokemonIndex, filterTerm: action.payload.filterTerm,
pokemonListFiltered: action.payload.pokemonListFiltered,
});
const reduceSetActivePokemonId = (
state : IPokemonSelectListState,
action : ReturnType<typeof Actions.setActivePokemonId>
) : IPokemonSelectListState => ({
...state,
activePokemonId: action.payload.activePokemonId,
}); });
const reduceSetPokemonLeagueStats = ( const reduceSetPokemonLeagueStats = (
@ -55,8 +65,10 @@ export const PokemonSelectListReducers : Reducer<IPokemonSelectListState> = (
return reduceSetIsLoading(state, action as ReturnType<typeof Actions.setIsLoading>); return reduceSetIsLoading(state, action as ReturnType<typeof Actions.setIsLoading>);
case PokemonSelectListActionTypes.SET_POKEMON_LIST: case PokemonSelectListActionTypes.SET_POKEMON_LIST:
return reduceSetPokemonList(state, action as ReturnType<typeof Actions.setPokemonList>); return reduceSetPokemonList(state, action as ReturnType<typeof Actions.setPokemonList>);
case PokemonSelectListActionTypes.SET_ACTIVE_POKEMON_INDEX: case PokemonSelectListActionTypes.SET_POKEMON_LIST_FILTERED:
return reduceSetActivePokemonIndex(state, action as ReturnType<typeof Actions.setActivePokemonIndex>); 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: case PokemonSelectListActionTypes.SET_POKEMON_LEAGUE_STATS:
return reduceSetPokemonLeagueStats(state, action as ReturnType<typeof Actions.setPokemonLeagueStats>); return reduceSetPokemonLeagueStats(state, action as ReturnType<typeof Actions.setPokemonLeagueStats>);
default: default:

View File

@ -4,10 +4,28 @@
height: 100vh; height: 100vh;
font-size: 0.8rem; font-size: 0.8rem;
flex-basis: 20em; flex-basis: 20em;
display: flex;
flex-flow: column nowrap;
margin-left: 1rem;
.listWrapper {
flex: 1 1 auto;
display: flex;
padding: 6px; padding: 6px;
& > * { & > * {
height: 100%; width: 100%;
}
&.emptyList .emptyState {
align-self: center;
text-align: center;
margin-top: -100%;
& > *:first-child {
margin: 1em auto;
}
}
} }
a { a {
@ -18,6 +36,11 @@
} }
} }
.filterInput {
margin-left: 0;
margin-right: 0;
}
.dex, .dex,
.form { .form {
font-size: 0.8em; font-size: 0.8em;

View File

@ -1,5 +1,9 @@
// This file is automatically generated. // This file is automatically generated.
// Please do not change this file! // Please do not change this file!
export const dex: string; export const dex: string;
export const emptyList: string;
export const emptyState: string;
export const filterInput: string;
export const form: string; export const form: string;
export const leftPanel: string; export const leftPanel: string;
export const listWrapper: string;

View File

@ -2,15 +2,17 @@ import { ILeaguePokemon, IPokemon } from 'app/models/Pokemon';
export interface IPokemonSelectListState { export interface IPokemonSelectListState {
isLoading : boolean; isLoading : boolean;
activePokemonIndex : number | null; activePokemonId : string | null;
pokemonList : Array<IPokemon>; pokemonList : Array<IPokemon>;
pokemonListFiltered : Array<IPokemon>; pokemonListFiltered : Array<IPokemon>;
filterTerm : string;
pokemonLeagueStats : { [id : string] : ILeaguePokemon }; pokemonLeagueStats : { [id : string] : ILeaguePokemon };
} }
export const PokemonSelectListActionTypes = { export const PokemonSelectListActionTypes = {
SET_IS_LOADING: 'POKEMON_SELECT_LIST/SET_IS_LOADING', SET_IS_LOADING: 'POKEMON_SELECT_LIST/SET_IS_LOADING',
SET_POKEMON_LIST: 'POKEMON_SELECT_LIST/SET_POKEMON_LIST', SET_POKEMON_LIST: 'POKEMON_SELECT_LIST/SET_POKEMON_LIST',
SET_ACTIVE_POKEMON_INDEX: 'POKEMON_SELECT_LIST/SET_ACTIVE_POKEMON_INDEX', 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', SET_POKEMON_LEAGUE_STATS: 'POKEMON_SELECT_LIST/SET_POKEMON_LEAGUE_STATS',
}; };

View File

@ -6,8 +6,8 @@
align-items: stretch; align-items: stretch;
height: 100vh; height: 100vh;
& > * { // & > * {
flex-grow: 0; // flex-grow: 0;
flex-shrink: 0; // flex-shrink: 0;
} // }
} }