mirror of
https://github.com/aspnet/JavaScriptServices.git
synced 2026-02-08 17:16:04 +00:00
Beginning React+Redux "Music Store" sample
This commit is contained in:
62
samples/react/MusicStore/ReactApp/TypedRedux.ts
Normal file
62
samples/react/MusicStore/ReactApp/TypedRedux.ts
Normal 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);
|
||||
}
|
||||
19
samples/react/MusicStore/ReactApp/boot.tsx
Normal file
19
samples/react/MusicStore/ReactApp/boot.tsx
Normal 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')
|
||||
);
|
||||
37
samples/react/MusicStore/ReactApp/components/App.tsx
Normal file
37
samples/react/MusicStore/ReactApp/components/App.tsx
Normal 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>;
|
||||
}
|
||||
}
|
||||
49
samples/react/MusicStore/ReactApp/components/NavMenu.tsx
Normal file
49
samples/react/MusicStore/ReactApp/components/NavMenu.tsx
Normal 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);
|
||||
@@ -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);
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
37
samples/react/MusicStore/ReactApp/components/public/Home.tsx
Normal file
37
samples/react/MusicStore/ReactApp/components/public/Home.tsx
Normal 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);
|
||||
39
samples/react/MusicStore/ReactApp/configureStore.ts
Normal file
39
samples/react/MusicStore/ReactApp/configureStore.ts
Normal 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;
|
||||
}
|
||||
3
samples/react/MusicStore/ReactApp/isomorphic-fetch.d.ts
vendored
Normal file
3
samples/react/MusicStore/ReactApp/isomorphic-fetch.d.ts
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
declare module 'isomorphic-fetch' {
|
||||
export default function fetch(url: string): Promise<any>;
|
||||
}
|
||||
72
samples/react/MusicStore/ReactApp/store/AlbumDetails.ts
Normal file
72
samples/react/MusicStore/ReactApp/store/AlbumDetails.ts
Normal 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;
|
||||
}
|
||||
};
|
||||
58
samples/react/MusicStore/ReactApp/store/FeaturedAlbums.ts
Normal file
58
samples/react/MusicStore/ReactApp/store/FeaturedAlbums.ts
Normal 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: [] };
|
||||
}
|
||||
};
|
||||
59
samples/react/MusicStore/ReactApp/store/GenreDetails.ts
Normal file
59
samples/react/MusicStore/ReactApp/store/GenreDetails.ts
Normal 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;
|
||||
}
|
||||
};
|
||||
51
samples/react/MusicStore/ReactApp/store/GenreList.ts
Normal file
51
samples/react/MusicStore/ReactApp/store/GenreList.ts
Normal 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: [] };
|
||||
}
|
||||
};
|
||||
27
samples/react/MusicStore/ReactApp/store/index.ts
Normal file
27
samples/react/MusicStore/ReactApp/store/index.ts
Normal 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>;
|
||||
3
samples/react/MusicStore/ReactApp/styles/styles.css
Normal file
3
samples/react/MusicStore/ReactApp/styles/styles.css
Normal file
@@ -0,0 +1,3 @@
|
||||
body {
|
||||
padding-top: 50px;
|
||||
}
|
||||
Reference in New Issue
Block a user