Beginning React+Redux "Music Store" sample

This commit is contained in:
SteveSandersonMS
2016-02-05 23:28:13 +00:00
parent 35e620ae48
commit 5811c98230
69 changed files with 7508 additions and 6 deletions

View File

@@ -0,0 +1,62 @@
// Credits for the type detection trick: http://www.bluewire-technologies.com/2015/redux-actions-for-typescript/
import * as React from 'react';
import { Dispatch } from 'redux';
import { connect as nativeConnect, ElementClass } from 'react-redux';
interface ActionClass<T extends Action> {
prototype: T;
}
export function typeName(name: string) {
return function<T extends Action>(actionClass: ActionClass<T>) {
// Although we could determine the type name using actionClass.prototype.constructor.name,
// it's dangerous to do that because minifiers may interfere with it, and then serialized state
// might not have the expected meaning after a recompile. So we explicitly ask for a name string.
actionClass.prototype.type = name;
}
}
export function isActionType<T extends Action>(action: Action, actionClass: ActionClass<T>): action is T {
return action.type == actionClass.prototype.type;
}
export abstract class Action {
type: string;
constructor() {
// Make it an own-property (not a prototype property) so that it's included when JSON-serializing
this.type = this.type;
}
}
export interface Reducer<TState> extends Function {
(state: TState, action: Action): TState;
}
export interface ActionCreatorGeneric<TState> extends Function {
(dispatch: Dispatch, getState: () => TState): any;
}
interface ClassDecoratorWithProps<TProps> extends Function {
<T extends (typeof ElementClass)>(component: T): T;
props: TProps;
}
type ReactComponentClass<T, S> = new(props: T) => React.Component<T, S>;
class ComponentBuilder<TOwnProps, TActions, TExternalProps> {
constructor(private stateToProps: (appState: any) => TOwnProps, private actionCreators: TActions) {
}
public withExternalProps<TAddExternalProps>() {
return this as any as ComponentBuilder<TOwnProps, TActions, TAddExternalProps>;
}
public get allProps(): TOwnProps & TActions & TExternalProps { return null; }
public connect<TState>(componentClass: ReactComponentClass<TOwnProps & TActions & TExternalProps, TState>): ReactComponentClass<TExternalProps, TState> {
return nativeConnect(this.stateToProps, this.actionCreators as any)(componentClass);
}
}
export function provide<TOwnProps, TActions>(stateToProps: (appState: any) => TOwnProps, actionCreators: TActions) {
return new ComponentBuilder<TOwnProps, TActions, {}>(stateToProps, actionCreators);
}

View File

@@ -0,0 +1,19 @@
import * as React from 'react';
import * as ReactDOM from 'react-dom';
import { browserHistory } from 'react-router';
import { Provider } from 'react-redux';
React; // Need this reference otherwise TypeScript doesn't think we're using it and ignores the import
import './styles/styles.css';
import 'bootstrap/dist/css/bootstrap.css';
import configureStore from './configureStore';
import { App } from './components/App';
const store = configureStore(browserHistory);
ReactDOM.render(
<Provider store={ store }>
<App history={ browserHistory } />
</Provider>,
document.getElementById('react-app')
);

View File

@@ -0,0 +1,37 @@
import * as React from 'react';
import { Router, Route, HistoryBase } from 'react-router';
import NavMenu from './NavMenu';
import Home from './public/Home';
import Genres from './public/Genres';
import GenreDetails from './public/GenreDetails';
import AlbumDetails from './public/AlbumDetails';
export interface AppProps {
history: HistoryBase;
}
export class App extends React.Component<AppProps, void> {
public render() {
return (
<Router history={ this.props.history }>
<Route component={ Layout }>
<Route path="/" components={{ body: Home }} />
<Route path="/genres" components={{ body: Genres }} />
<Route path="/genre/:genreId" components={{ body: GenreDetails }} />
<Route path="/album/:albumId" components={{ body: AlbumDetails }} />
</Route>
</Router>
);
}
}
class Layout extends React.Component<{ body: React.ReactElement<any> }, void> {
public render() {
return <div>
<NavMenu />
<div className="container">
{ this.props.body }
</div>
</div>;
}
}

View File

@@ -0,0 +1,49 @@
import * as React from 'react';
import { Navbar, Nav, NavItem, NavDropdown, MenuItem } from 'react-bootstrap';
import { Link } from 'react-router';
import { LinkContainer } from 'react-router-bootstrap';
import { provide } from '../TypedRedux';
import { ApplicationState } from '../store';
import * as GenreList from '../store/GenreList';
class NavMenu extends React.Component<NavMenuProps, void> {
componentWillMount() {
this.props.requestGenresList();
}
public render() {
var genres = this.props.genres.slice(0, 5);
return (
<Navbar inverse fixedTop>
<Navbar.Header>
<Navbar.Brand><Link to={ '/' }>Music Store</Link></Navbar.Brand>
</Navbar.Header>
<Navbar.Collapse>
<Nav>
<LinkContainer to={ '/' }><NavItem>Home</NavItem></LinkContainer>
<NavDropdown id="menu-dropdown" title="Store">
{genres.map(genre =>
<LinkContainer key={ genre.GenreId } to={ `/genre/${ genre.GenreId }` }>
<MenuItem>{ genre.Name }</MenuItem>
</LinkContainer>
)}
<MenuItem divider />
<LinkContainer to={ '/genres' }><MenuItem>More</MenuItem></LinkContainer>
</NavDropdown>
</Nav>
<Nav pullRight>
<NavItem href="#">Admin</NavItem>
</Nav>
</Navbar.Collapse>
</Navbar>
);
}
}
// Selects which part of global state maps to this component, and defines a type for the resulting props
const provider = provide(
(state: ApplicationState) => state.genreList,
GenreList.actionCreators
);
type NavMenuProps = typeof provider.allProps;
export default provider.connect(NavMenu);

View File

@@ -0,0 +1,60 @@
import * as React from 'react';
import { Link } from 'react-router';
import { provide } from '../../TypedRedux';
import { ApplicationState } from '../../store';
import * as AlbumDetailsState from '../../store/AlbumDetails';
interface RouteParams {
albumId: number;
}
class AlbumDetails extends React.Component<AlbumDetailsProps, void> {
componentWillMount() {
this.props.requestAlbumDetails(this.props.params.albumId);
}
componentWillReceiveProps(nextProps: AlbumDetailsProps) {
if (nextProps.params.albumId !== this.props.params.albumId) {
nextProps.requestAlbumDetails(nextProps.params.albumId);
}
}
public render() {
if (this.props.isLoaded) {
const albumData = this.props.album;
return <div>
<h2>{ albumData.Title }</h2>
<p><img alt={ albumData.Title } src={ albumData.AlbumArtUrl } /></p>
<div id="album-details">
<p>
<em>Genre:</em>
{ albumData.Genre.Name }
</p>
<p>
<em>Artist:</em>
{ albumData.Artist.Name }
</p>
<p>
<em>Price:</em>
${ albumData.Price.toFixed(2) }
</p>
<p className="button">
Add to cart
</p>
</div>
</div>;
} else {
return <p>Loading...</p>;
}
}
}
// Selects which part of global state maps to this component, and defines a type for the resulting props
const provider = provide(
(state: ApplicationState) => state.albumDetails,
AlbumDetailsState.actionCreators
).withExternalProps<{ params: RouteParams }>();
type AlbumDetailsProps = typeof provider.allProps;
export default provider.connect(AlbumDetails);

View File

@@ -0,0 +1,17 @@
import * as React from 'react';
import { Link } from 'react-router';
import { Album } from '../../store/FeaturedAlbums';
export class AlbumTile extends React.Component<{ album: Album, key?: any }, void> {
public render() {
const { album } = this.props;
return (
<li className="col-lg-2 col-md-2 col-sm-2 col-xs-4 container">
<Link to={ '/album/' + album.AlbumId }>
<img alt={ album.Title } src={ album.AlbumArtUrl } />
<h4>{ album.Title }</h4>
</Link>
</li>
);
}
}

View File

@@ -0,0 +1,48 @@
import * as React from 'react';
import { Link } from 'react-router';
import { provide } from '../../TypedRedux';
import { ApplicationState } from '../../store';
import * as GenreDetailsStore from '../../store/GenreDetails';
import { AlbumTile } from './AlbumTile';
interface RouteParams {
genreId: number
}
class GenreDetails extends React.Component<GenreDetailsProps, void> {
componentWillMount() {
this.props.requestGenreDetails(this.props.params.genreId);
}
componentWillReceiveProps(nextProps: GenreDetailsProps) {
if (nextProps.params.genreId !== this.props.params.genreId) {
nextProps.requestGenreDetails(nextProps.params.genreId);
}
}
public render() {
if (this.props.isLoaded) {
let albums = this.props.albums;
return <div>
<h3>Albums</h3>
<ul className="list-unstyled">
{albums.map(album =>
<AlbumTile key={ album.AlbumId } album={ album } />
)}
</ul>
</div>;
} else {
return <p>Loading...</p>;
}
}
}
// Selects which part of global state maps to this component, and defines a type for the resulting props
const provider = provide(
(state: ApplicationState) => state.genreDetails,
GenreDetailsStore.actionCreators
).withExternalProps<{ params: RouteParams }>();
type GenreDetailsProps = typeof provider.allProps;
export default provider.connect(GenreDetails);

View File

@@ -0,0 +1,41 @@
import * as React from 'react';
import { Link } from 'react-router';
import { provide } from '../../TypedRedux';
import { ApplicationState } from '../../store';
import * as GenreList from '../../store/GenreList';
class Genres extends React.Component<GenresProps, void> {
componentWillMount() {
if (!this.props.genres.length) {
this.props.requestGenresList();
}
}
public render() {
let { genres } = this.props;
return <div>
<h3>Browse Genres</h3>
<p>Select from { genres.length || '...' } genres:</p>
<ul className="list-group">
{genres.map(genre =>
<li key={ genre.GenreId } className="list-group-item">
<Link to={ '/genre/' + genre.GenreId }>
{ genre.Name }
</Link>
</li>
)}
</ul>
</div>;
}
}
// Selects which part of global state maps to this component, and defines a type for the resulting props
const provider = provide(
(state: ApplicationState) => state.genreList,
GenreList.actionCreators
);
type GenresProps = typeof provider.allProps;
export default provider.connect(Genres);

View File

@@ -0,0 +1,37 @@
import * as React from 'react';
import { Link } from 'react-router';
import { provide } from '../../TypedRedux';
import { ApplicationState } from '../../store';
import { actionCreators } from '../../store/FeaturedAlbums';
import { AlbumTile } from './AlbumTile';
class Home extends React.Component<HomeProps, void> {
componentWillMount() {
if (!this.props.albums.length) {
this.props.requestFeaturedAlbums();
}
}
public render() {
let { albums } = this.props;
return <div>
<div className="jumbotron">
<h1>MVC Music Store</h1>
<img src="/Images/home-showcase.png" />
</div>
<ul className="row list-unstyled" id="album-list">
{albums.map(album =>
<AlbumTile key={ album.AlbumId } album={ album } />
)}
</ul>
</div>;
}
}
// Selects which part of global state maps to this component, and defines a type for the resulting props
const provider = provide(
(state: ApplicationState) => state.featuredAlbums,
actionCreators
);
type HomeProps = typeof provider.allProps;
export default provider.connect(Home);

View File

@@ -0,0 +1,39 @@
import { createStore, applyMiddleware, compose, combineReducers } from 'redux';
import * as thunkModule from 'redux-thunk';
import { syncHistory, routeReducer } from 'react-router-redux';
import * as Store from './store';
export default function configureStore(history: HistoryModule.History, initialState?: Store.ApplicationState) {
// Build middleware
const thunk = (thunkModule as any).default; // Workaround for TypeScript not importing thunk module as expected
const reduxRouterMiddleware = syncHistory(history);
const middlewares = [thunk, reduxRouterMiddleware];
const devToolsExtension = (window as any).devToolsExtension; // If devTools is installed, connect to it
const finalCreateStore = compose(
applyMiddleware(...middlewares),
devToolsExtension ? devToolsExtension() : f => f
)(createStore)
// Combine all reducers
const allReducers = buildRootReducer(Store.reducers);
const store = finalCreateStore(allReducers, initialState) as Redux.Store;
// Required for replaying actions from devtools to work
reduxRouterMiddleware.listenForReplays(store);
// Enable Webpack hot module replacement for reducers
if (module.hot) {
module.hot.accept('./store', () => {
const nextRootReducer = require<typeof Store>('./store');
store.replaceReducer(buildRootReducer(nextRootReducer.reducers));
});
}
return store;
}
function buildRootReducer(allReducers) {
return combineReducers(Object.assign({}, allReducers, { routing: routeReducer })) as Redux.Reducer;
}

View File

@@ -0,0 +1,3 @@
declare module 'isomorphic-fetch' {
export default function fetch(url: string): Promise<any>;
}

View File

@@ -0,0 +1,72 @@
import fetch from 'isomorphic-fetch';
import { typeName, isActionType, Action, Reducer } from '../TypedRedux';
import { ActionCreator } from './';
import { Genre } from './GenreList';
// -----------------
// STATE - This defines the type of data maintained in the Redux store.
export interface AlbumDetailsState {
isLoaded: boolean;
album: AlbumDetails;
}
export interface AlbumDetails {
AlbumId: string;
Title: string;
AlbumArtUrl: string;
Genre: Genre;
Artist: Artist;
Price: number;
}
interface Artist {
Name: string;
}
// -----------------
// ACTIONS - These are serializable (hence replayable) descriptions of state transitions.
// They do not themselves have any side-effects; they just describe something that is going to happen.
// Use @typeName and isActionType for type detection that works even after serialization/deserialization.
@typeName("REQUEST_ALBUM_DETAILS")
class RequestAlbumDetails extends Action {
constructor(public albumId: number) {
super();
}
}
@typeName("RECEIVE_ALBUM_DETAILS")
class ReceiveAlbumDetails extends Action {
constructor(public album: AlbumDetails) {
super();
}
}
// ----------------
// ACTION CREATORS - These are functions exposed to UI components that will trigger a state transition.
// They don't directly mutate state, but they can have external side-effects (such as loading data).
export const actionCreators = {
requestAlbumDetails: (albumId: number): ActionCreator => (dispatch, getState) => {
fetch(`/api/albums/${ albumId }`)
.then(results => results.json())
.then(album => dispatch(new ReceiveAlbumDetails(album)));
dispatch(new RequestAlbumDetails(albumId));
}
};
// ----------------
// REDUCER - For a given state and action, returns the new state. To support time travel, this must not mutate the old state.
// For unrecognized actions, must return the existing state (or default initial state if none was supplied).
const unloadedState: AlbumDetailsState = { isLoaded: false, album: null };
export const reducer: Reducer<AlbumDetailsState> = (state, action) => {
if (isActionType(action, RequestAlbumDetails)) {
return unloadedState;
} else if (isActionType(action, ReceiveAlbumDetails)) {
return { isLoaded: true, album: action.album };
} else {
return state || unloadedState;
}
};

View File

@@ -0,0 +1,58 @@
import fetch from 'isomorphic-fetch';
import { typeName, isActionType, Action, Reducer } from '../TypedRedux';
import { ActionCreator } from './';
// -----------------
// STATE - This defines the type of data maintained in the Redux store.
export interface FeaturedAlbumsState {
albums: Album[];
}
export interface Album {
AlbumId: number;
Title: string;
AlbumArtUrl: string;
}
// -----------------
// ACTIONS - These are serializable (hence replayable) descriptions of state transitions.
// They do not themselves have any side-effects; they just describe something that is going to happen.
// Use @typeName and isActionType for type detection that works even after serialization/deserialization.
@typeName("REQUEST_FEATURED_ALBUMS")
class RequestFeaturedAlbums extends Action {
}
@typeName("RECEIVE_FEATURED_ALBUMS")
class ReceiveFeaturedAlbums extends Action {
constructor(public albums: Album[]) {
super();
}
}
// ----------------
// ACTION CREATORS - These are functions exposed to UI components that will trigger a state transition.
// They don't directly mutate state, but they can have external side-effects (such as loading data).
export const actionCreators = {
requestFeaturedAlbums: (): ActionCreator => (dispatch, getState) => {
fetch('/api/albums/mostPopular')
.then(results => results.json())
.then(albums => dispatch(new ReceiveFeaturedAlbums(albums)));
return dispatch(new RequestFeaturedAlbums());
}
};
// ----------------
// REDUCER - For a given state and action, returns the new state. To support time travel, this must not mutate the old state.
// For unrecognized actions, must return the existing state (or default initial state if none was supplied).
export const reducer: Reducer<FeaturedAlbumsState> = (state, action) => {
if (isActionType(action, ReceiveFeaturedAlbums)) {
return { albums: action.albums };
} else {
return state || { albums: [] };
}
};

View File

@@ -0,0 +1,59 @@
import fetch from 'isomorphic-fetch';
import { typeName, isActionType, Action, Reducer } from '../TypedRedux';
import { ActionCreator } from './';
import { Album } from './FeaturedAlbums';
// -----------------
// STATE - This defines the type of data maintained in the Redux store.
export interface GenreDetailsState {
isLoaded: boolean;
albums: Album[];
}
// -----------------
// ACTIONS - These are serializable (hence replayable) descriptions of state transitions.
// They do not themselves have any side-effects; they just describe something that is going to happen.
// Use @typeName and isActionType for type detection that works even after serialization/deserialization.
@typeName("REQUEST_GENRE_DETAILS")
class RequestGenreDetails extends Action {
constructor(public genreId: number) {
super();
}
}
@typeName("RECEIVE_GENRE_DETAILS")
class ReceiveGenreDetails extends Action {
constructor(public albums: Album[]) {
super();
}
}
// ----------------
// ACTION CREATORS - These are functions exposed to UI components that will trigger a state transition.
// They don't directly mutate state, but they can have external side-effects (such as loading data).
export const actionCreators = {
requestGenreDetails: (genreId: number): ActionCreator => (dispatch, getState) => {
fetch(`/api/genres/${ genreId }/albums`)
.then(results => results.json())
.then(albums => dispatch(new ReceiveGenreDetails(albums)));
dispatch(new RequestGenreDetails(genreId));
}
};
// ----------------
// REDUCER - For a given state and action, returns the new state. To support time travel, this must not mutate the old state.
// For unrecognized actions, must return the existing state (or default initial state if none was supplied).
const unloadedState: GenreDetailsState = { isLoaded: false, albums: [] };
export const reducer: Reducer<GenreDetailsState> = (state, action) => {
if (isActionType(action, RequestGenreDetails)) {
return unloadedState;
} else if (isActionType(action, ReceiveGenreDetails)) {
return { isLoaded: true, albums: action.albums };
} else {
return state || unloadedState;
}
};

View File

@@ -0,0 +1,51 @@
import fetch from 'isomorphic-fetch';
import { typeName, isActionType, Action, Reducer } from '../TypedRedux';
import { ActionCreator } from './';
// -----------------
// STATE - This defines the type of data maintained in the Redux store.
export interface GenresListState {
genres: Genre[];
}
export interface Genre {
GenreId: string;
Name: string;
}
// -----------------
// ACTIONS - These are serializable (hence replayable) descriptions of state transitions.
// They do not themselves have any side-effects; they just describe something that is going to happen.
// Use @typeName and isActionType for type detection that works even after serialization/deserialization.
@typeName("RECEIVE_GENRES_LIST")
class ReceiveGenresList extends Action {
constructor(public genres: Genre[]) {
super();
}
}
// ----------------
// ACTION CREATORS - These are functions exposed to UI components that will trigger a state transition.
// They don't directly mutate state, but they can have external side-effects (such as loading data).
export const actionCreators = {
requestGenresList: (): ActionCreator => (dispatch, getState) => {
fetch('/api/genres')
.then(results => results.json())
.then(genres => dispatch(new ReceiveGenresList(genres)));
}
};
// ----------------
// REDUCER - For a given state and action, returns the new state. To support time travel, this must not mutate the old state.
// For unrecognized actions, must return the existing state (or default initial state if none was supplied).
export const reducer: Reducer<GenresListState> = (state, action) => {
if (isActionType(action, ReceiveGenresList)) {
return { genres: action.genres };
} else {
return state || { genres: [] };
}
};

View File

@@ -0,0 +1,27 @@
import { ActionCreatorGeneric } from '../TypedRedux';
import * as FeaturedAlbums from './FeaturedAlbums';
import * as GenreList from './GenreList';
import * as GenreDetails from './GenreDetails';
import * as AlbumDetails from './AlbumDetails';
// The top-level state object
export interface ApplicationState {
featuredAlbums: FeaturedAlbums.FeaturedAlbumsState;
genreList: GenreList.GenresListState,
genreDetails: GenreDetails.GenreDetailsState,
albumDetails: AlbumDetails.AlbumDetailsState
}
// Whenever an action is dispatched, Redux will update each top-level application state property using
// the reducer with the matching name. It's important that the names match exactly, and that the reducer
// acts on the corresponding ApplicationState property type.
export const reducers = {
featuredAlbums: FeaturedAlbums.reducer,
genreList: GenreList.reducer,
genreDetails: GenreDetails.reducer,
albumDetails: AlbumDetails.reducer
};
// This type can be used as a hint on action creators so that its 'dispatch' and 'getState' params are
// correctly typed to match your store.
export type ActionCreator = ActionCreatorGeneric<ApplicationState>;

View File

@@ -0,0 +1,3 @@
body {
padding-top: 50px;
}