diff --git a/src/ProjectTemplates/Web.Spa.ProjectTemplates/content/ReactRedux-CSharp/ClientApp/.eslintrc.json b/src/ProjectTemplates/Web.Spa.ProjectTemplates/content/ReactRedux-CSharp/ClientApp/.eslintrc.json new file mode 100644 index 0000000000..a07f42e6a8 --- /dev/null +++ b/src/ProjectTemplates/Web.Spa.ProjectTemplates/content/ReactRedux-CSharp/ClientApp/.eslintrc.json @@ -0,0 +1,3 @@ +{ + "parser": "typescript-eslint-parser" +} \ No newline at end of file diff --git a/src/ProjectTemplates/Web.Spa.ProjectTemplates/content/ReactRedux-CSharp/ClientApp/src/App.test.tsx b/src/ProjectTemplates/Web.Spa.ProjectTemplates/content/ReactRedux-CSharp/ClientApp/src/App.test.tsx new file mode 100644 index 0000000000..8ad9b2d9f3 --- /dev/null +++ b/src/ProjectTemplates/Web.Spa.ProjectTemplates/content/ReactRedux-CSharp/ClientApp/src/App.test.tsx @@ -0,0 +1,22 @@ +import * as React from 'react'; +import * as ReactDOM from 'react-dom'; +import { Provider } from 'react-redux'; +import { MemoryRouter } from 'react-router-dom'; +import App from './App'; + +it('renders without crashing', () => { + const storeFake = (state: any) => ({ + default: () => {}, + subscribe: () => {}, + dispatch: () => {}, + getState: () => ({ ...state }) + }); + const store = storeFake({}); + + ReactDOM.render( + + + + + , document.createElement('div')); +}); diff --git a/src/ProjectTemplates/Web.Spa.ProjectTemplates/content/ReactRedux-CSharp/ClientApp/src/App.tsx b/src/ProjectTemplates/Web.Spa.ProjectTemplates/content/ReactRedux-CSharp/ClientApp/src/App.tsx new file mode 100644 index 0000000000..f2e22cf5ab --- /dev/null +++ b/src/ProjectTemplates/Web.Spa.ProjectTemplates/content/ReactRedux-CSharp/ClientApp/src/App.tsx @@ -0,0 +1,14 @@ +import * as React from 'react'; +import { Route } from 'react-router'; +import Layout from './components/Layout'; +import Home from './components/Home'; +import Counter from './components/Counter'; +import FetchData from './components/FetchData'; + +export default () => ( + + + + + +); diff --git a/src/ProjectTemplates/Web.Spa.ProjectTemplates/content/ReactRedux-CSharp/ClientApp/src/components/Counter.js b/src/ProjectTemplates/Web.Spa.ProjectTemplates/content/ReactRedux-CSharp/ClientApp/src/components/Counter.js deleted file mode 100644 index 6513638424..0000000000 --- a/src/ProjectTemplates/Web.Spa.ProjectTemplates/content/ReactRedux-CSharp/ClientApp/src/components/Counter.js +++ /dev/null @@ -1,21 +0,0 @@ -import React from 'react'; -import { bindActionCreators } from 'redux'; -import { connect } from 'react-redux'; -import { actionCreators } from '../store/Counter'; - -const Counter = props => ( -
-

Counter

- -

This is a simple example of a React component.

- -

Current count: {props.count}

- - -
-); - -export default connect( - state => state.counter, - dispatch => bindActionCreators(actionCreators, dispatch) -)(Counter); diff --git a/src/ProjectTemplates/Web.Spa.ProjectTemplates/content/ReactRedux-CSharp/ClientApp/src/components/Counter.tsx b/src/ProjectTemplates/Web.Spa.ProjectTemplates/content/ReactRedux-CSharp/ClientApp/src/components/Counter.tsx new file mode 100644 index 0000000000..a31720c44b --- /dev/null +++ b/src/ProjectTemplates/Web.Spa.ProjectTemplates/content/ReactRedux-CSharp/ClientApp/src/components/Counter.tsx @@ -0,0 +1,35 @@ +import * as React from 'react'; +import { connect } from 'react-redux'; +import { RouteComponentProps } from 'react-router'; +import { ApplicationState } from '../store'; +import * as CounterStore from '../store/Counter'; + +type CounterProps = + CounterStore.CounterState & + typeof CounterStore.actionCreators & + RouteComponentProps<{}>; + +class Counter extends React.PureComponent { + public render() { + return ( + +

Counter

+ +

This is a simple example of a React component.

+ +

Current count: {this.props.count}

+ + +
+ ); + } +}; + +export default connect( + (state: ApplicationState) => state.counter, + CounterStore.actionCreators +)(Counter); diff --git a/src/ProjectTemplates/Web.Spa.ProjectTemplates/content/ReactRedux-CSharp/ClientApp/src/components/FetchData.js b/src/ProjectTemplates/Web.Spa.ProjectTemplates/content/ReactRedux-CSharp/ClientApp/src/components/FetchData.js deleted file mode 100644 index 73a98881ed..0000000000 --- a/src/ProjectTemplates/Web.Spa.ProjectTemplates/content/ReactRedux-CSharp/ClientApp/src/components/FetchData.js +++ /dev/null @@ -1,74 +0,0 @@ -import React, { Component } from 'react'; -import { bindActionCreators } from 'redux'; -import { connect } from 'react-redux'; -import { Link } from 'react-router-dom'; -import { actionCreators } from '../store/WeatherForecasts'; - -class FetchData extends Component { - componentDidMount() { - // This method is called when the component is first added to the document - this.ensureDataFetched(); - } - - componentDidUpdate() { - // This method is called when the route parameters change - this.ensureDataFetched(); - } - - ensureDataFetched() { - const startDateIndex = parseInt(this.props.match.params.startDateIndex, 10) || 0; - this.props.requestWeatherForecasts(startDateIndex); - } - - render() { - return ( -
-

Weather forecast

-

This component demonstrates fetching data from the server and working with URL parameters.

- {renderForecastsTable(this.props)} - {renderPagination(this.props)} -
- ); - } -} - -function renderForecastsTable(props) { - return ( - - - - - - - - - - - {props.forecasts.map(forecast => - - - - - - - )} - -
DateTemp. (C)Temp. (F)Summary
{forecast.dateFormatted}{forecast.temperatureC}{forecast.temperatureF}{forecast.summary}
- ); -} - -function renderPagination(props) { - const prevStartDateIndex = (props.startDateIndex || 0) - 5; - const nextStartDateIndex = (props.startDateIndex || 0) + 5; - - return

- Previous - Next - {props.isLoading ? Loading... : []} -

; -} - -export default connect( - state => state.weatherForecasts, - dispatch => bindActionCreators(actionCreators, dispatch) -)(FetchData); diff --git a/src/ProjectTemplates/Web.Spa.ProjectTemplates/content/ReactRedux-CSharp/ClientApp/src/components/FetchData.tsx b/src/ProjectTemplates/Web.Spa.ProjectTemplates/content/ReactRedux-CSharp/ClientApp/src/components/FetchData.tsx new file mode 100644 index 0000000000..cdd33205ef --- /dev/null +++ b/src/ProjectTemplates/Web.Spa.ProjectTemplates/content/ReactRedux-CSharp/ClientApp/src/components/FetchData.tsx @@ -0,0 +1,84 @@ +import * as React from 'react'; +import { connect } from 'react-redux'; +import { RouteComponentProps } from 'react-router'; +import { Link } from 'react-router-dom'; +import { ApplicationState } from '../store'; +import * as WeatherForecastsStore from '../store/WeatherForecasts'; + +// At runtime, Redux will merge together... +type WeatherForecastProps = + WeatherForecastsStore.WeatherForecastsState // ... state we've requested from the Redux store + & typeof WeatherForecastsStore.actionCreators // ... plus action creators we've requested + & RouteComponentProps<{ startDateIndex: string }>; // ... plus incoming routing parameters + + +class FetchData extends React.PureComponent { + // This method is called when the component is first added to the document + public componentDidMount() { + this.ensureDataFetched(); + } + + // This method is called when the route parameters change + public componentDidUpdate() { + this.ensureDataFetched(); + } + + public render() { + return ( + +

Weather forecast

+

This component demonstrates fetching data from the server and working with URL parameters.

+ { this.renderForecastsTable() } + { this.renderPagination() } +
+ ); + } + + private ensureDataFetched() { + const startDateIndex = parseInt(this.props.match.params.startDateIndex, 10) || 0; + this.props.requestWeatherForecasts(startDateIndex); + } + + private renderForecastsTable() { + return ( + + + + + + + + + + + {this.props.forecasts.map((forecast: WeatherForecastsStore.WeatherForecast) => + + + + + + + )} + +
DateTemp. (C)Temp. (F)Summary
{forecast.dateFormatted}{forecast.temperatureC}{forecast.temperatureF}{forecast.summary}
+ ); + } + + private renderPagination() { + const prevStartDateIndex = (this.props.startDateIndex || 0) - 5; + const nextStartDateIndex = (this.props.startDateIndex || 0) + 5; + + return ( +
+ Previous + {this.props.isLoading && Loading...} + Next +
+ ); + } +} + +export default connect( + (state: ApplicationState) => state.weatherForecasts, // Selects which state properties are merged into the component's props + WeatherForecastsStore.actionCreators // Selects which action creators are merged into the component's props +)(FetchData as any); diff --git a/src/ProjectTemplates/Web.Spa.ProjectTemplates/content/ReactRedux-CSharp/ClientApp/src/components/Home.js b/src/ProjectTemplates/Web.Spa.ProjectTemplates/content/ReactRedux-CSharp/ClientApp/src/components/Home.tsx similarity index 96% rename from src/ProjectTemplates/Web.Spa.ProjectTemplates/content/ReactRedux-CSharp/ClientApp/src/components/Home.js rename to src/ProjectTemplates/Web.Spa.ProjectTemplates/content/ReactRedux-CSharp/ClientApp/src/components/Home.tsx index d2d6c8258b..5f7d5ff661 100644 --- a/src/ProjectTemplates/Web.Spa.ProjectTemplates/content/ReactRedux-CSharp/ClientApp/src/components/Home.js +++ b/src/ProjectTemplates/Web.Spa.ProjectTemplates/content/ReactRedux-CSharp/ClientApp/src/components/Home.tsx @@ -1,7 +1,7 @@ -import React from 'react'; +import * as React from 'react'; import { connect } from 'react-redux'; -const Home = props => ( +const Home = () => (

Hello, world!

Welcome to your new single-page application, built with:

diff --git a/src/ProjectTemplates/Web.Spa.ProjectTemplates/content/ReactRedux-CSharp/ClientApp/src/components/Layout.js b/src/ProjectTemplates/Web.Spa.ProjectTemplates/content/ReactRedux-CSharp/ClientApp/src/components/Layout.js deleted file mode 100644 index 216fca9296..0000000000 --- a/src/ProjectTemplates/Web.Spa.ProjectTemplates/content/ReactRedux-CSharp/ClientApp/src/components/Layout.js +++ /dev/null @@ -1,12 +0,0 @@ -import React from 'react'; -import { Container } from 'reactstrap'; -import NavMenu from './NavMenu'; - -export default props => ( -
- - - {props.children} - -
-); diff --git a/src/ProjectTemplates/Web.Spa.ProjectTemplates/content/ReactRedux-CSharp/ClientApp/src/components/Layout.tsx b/src/ProjectTemplates/Web.Spa.ProjectTemplates/content/ReactRedux-CSharp/ClientApp/src/components/Layout.tsx new file mode 100644 index 0000000000..80ddb46adb --- /dev/null +++ b/src/ProjectTemplates/Web.Spa.ProjectTemplates/content/ReactRedux-CSharp/ClientApp/src/components/Layout.tsx @@ -0,0 +1,12 @@ +import * as React from 'react'; +import { Container } from 'reactstrap'; +import NavMenu from './NavMenu'; + +export default (props: { children?: React.ReactNode }) => ( + + + + {props.children} + + +); diff --git a/src/ProjectTemplates/Web.Spa.ProjectTemplates/content/ReactRedux-CSharp/ClientApp/src/components/NavMenu.js b/src/ProjectTemplates/Web.Spa.ProjectTemplates/content/ReactRedux-CSharp/ClientApp/src/components/NavMenu.js deleted file mode 100644 index 9eb976ab8a..0000000000 --- a/src/ProjectTemplates/Web.Spa.ProjectTemplates/content/ReactRedux-CSharp/ClientApp/src/components/NavMenu.js +++ /dev/null @@ -1,45 +0,0 @@ -import React from 'react'; -import { Collapse, Container, Navbar, NavbarBrand, NavbarToggler, NavItem, NavLink } from 'reactstrap'; -import { Link } from 'react-router-dom'; -import './NavMenu.css'; - -export default class NavMenu extends React.Component { - constructor (props) { - super(props); - - this.toggle = this.toggle.bind(this); - this.state = { - isOpen: false - }; - } - toggle () { - this.setState({ - isOpen: !this.state.isOpen - }); - } - render () { - return ( -
- - - Company.WebApplication1 - - -
    - - Home - - - Counter - - - Fetch data - -
-
-
-
-
- ); - } -} diff --git a/src/ProjectTemplates/Web.Spa.ProjectTemplates/content/ReactRedux-CSharp/ClientApp/src/components/NavMenu.tsx b/src/ProjectTemplates/Web.Spa.ProjectTemplates/content/ReactRedux-CSharp/ClientApp/src/components/NavMenu.tsx new file mode 100644 index 0000000000..9d13ae352c --- /dev/null +++ b/src/ProjectTemplates/Web.Spa.ProjectTemplates/content/ReactRedux-CSharp/ClientApp/src/components/NavMenu.tsx @@ -0,0 +1,42 @@ +import * as React from 'react'; +import { Collapse, Container, Navbar, NavbarBrand, NavbarToggler, NavItem, NavLink } from 'reactstrap'; +import { Link } from 'react-router-dom'; +import './NavMenu.css'; + +export default class NavMenu extends React.PureComponent<{}, { isOpen: boolean }> { + public state = { + isOpen: false + }; + + public render() { + return ( +
+ + + Company.WebApplication1 + + +
    + + Home + + + Counter + + + Fetch data + +
+
+
+
+
+ ); + } + + private toggle = () => { + this.setState({ + isOpen: !this.state.isOpen + }); + } +} diff --git a/src/ProjectTemplates/Web.Spa.ProjectTemplates/content/ReactRedux-CSharp/ClientApp/src/index.js b/src/ProjectTemplates/Web.Spa.ProjectTemplates/content/ReactRedux-CSharp/ClientApp/src/index.tsx similarity index 58% rename from src/ProjectTemplates/Web.Spa.ProjectTemplates/content/ReactRedux-CSharp/ClientApp/src/index.js rename to src/ProjectTemplates/Web.Spa.ProjectTemplates/content/ReactRedux-CSharp/ClientApp/src/index.tsx index 5faeb680ba..d0f11bd4f6 100644 --- a/src/ProjectTemplates/Web.Spa.ProjectTemplates/content/ReactRedux-CSharp/ClientApp/src/index.js +++ b/src/ProjectTemplates/Web.Spa.ProjectTemplates/content/ReactRedux-CSharp/ClientApp/src/index.tsx @@ -1,29 +1,27 @@ import 'bootstrap/dist/css/bootstrap.css'; -import React from 'react'; -import ReactDOM from 'react-dom'; + +import * as React from 'react'; +import * as ReactDOM from 'react-dom'; import { Provider } from 'react-redux'; -import { ConnectedRouter } from 'react-router-redux'; +import { ConnectedRouter } from 'connected-react-router'; import { createBrowserHistory } from 'history'; import configureStore from './store/configureStore'; import App from './App'; import registerServiceWorker from './registerServiceWorker'; // Create browser history to use in the Redux store -const baseUrl = document.getElementsByTagName('base')[0].getAttribute('href'); +const baseUrl = document.getElementsByTagName('base')[0].getAttribute('href') as string; const history = createBrowserHistory({ basename: baseUrl }); // Get the application-wide store instance, prepopulating with state from the server where available. -const initialState = window.initialReduxState; -const store = configureStore(history, initialState); - -const rootElement = document.getElementById('root'); +const store = configureStore(history); ReactDOM.render( - - - - - , - rootElement); + + + + + , + document.getElementById('root')); registerServiceWorker(); diff --git a/src/ProjectTemplates/Web.Spa.ProjectTemplates/content/ReactRedux-CSharp/ClientApp/src/react-app-env.d.ts b/src/ProjectTemplates/Web.Spa.ProjectTemplates/content/ReactRedux-CSharp/ClientApp/src/react-app-env.d.ts new file mode 100644 index 0000000000..6431bc5fc6 --- /dev/null +++ b/src/ProjectTemplates/Web.Spa.ProjectTemplates/content/ReactRedux-CSharp/ClientApp/src/react-app-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/src/ProjectTemplates/Web.Spa.ProjectTemplates/content/ReactRedux-CSharp/ClientApp/src/registerServiceWorker.js b/src/ProjectTemplates/Web.Spa.ProjectTemplates/content/ReactRedux-CSharp/ClientApp/src/registerServiceWorker.js deleted file mode 100644 index 12542ba229..0000000000 --- a/src/ProjectTemplates/Web.Spa.ProjectTemplates/content/ReactRedux-CSharp/ClientApp/src/registerServiceWorker.js +++ /dev/null @@ -1,108 +0,0 @@ -// In production, we register a service worker to serve assets from local cache. - -// This lets the app load faster on subsequent visits in production, and gives -// it offline capabilities. However, it also means that developers (and users) -// will only see deployed updates on the "N+1" visit to a page, since previously -// cached resources are updated in the background. - -// To learn more about the benefits of this model, read https://goo.gl/KwvDNy. -// This link also includes instructions on opting out of this behavior. - -const isLocalhost = Boolean( - window.location.hostname === 'localhost' || - // [::1] is the IPv6 localhost address. - window.location.hostname === '[::1]' || - // 127.0.0.1/8 is considered localhost for IPv4. - window.location.hostname.match( - /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/ - ) -); - -export default function register() { - if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) { - // The URL constructor is available in all browsers that support SW. - const publicUrl = new URL(process.env.PUBLIC_URL, window.location); - if (publicUrl.origin !== window.location.origin) { - // Our service worker won't work if PUBLIC_URL is on a different origin - // from what our page is served on. This might happen if a CDN is used to - // serve assets; see https://github.com/facebookincubator/create-react-app/issues/2374 - return; - } - - window.addEventListener('load', () => { - const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`; - - if (isLocalhost) { - // This is running on localhost. Lets check if a service worker still exists or not. - checkValidServiceWorker(swUrl); - } else { - // Is not local host. Just register service worker - registerValidSW(swUrl); - } - }); - } -} - -function registerValidSW(swUrl) { - navigator.serviceWorker - .register(swUrl) - .then(registration => { - registration.onupdatefound = () => { - const installingWorker = registration.installing; - installingWorker.onstatechange = () => { - if (installingWorker.state === 'installed') { - if (navigator.serviceWorker.controller) { - // At this point, the old content will have been purged and - // the fresh content will have been added to the cache. - // It's the perfect time to display a "New content is - // available; please refresh." message in your web app. - console.log('New content is available; please refresh.'); - } else { - // At this point, everything has been precached. - // It's the perfect time to display a - // "Content is cached for offline use." message. - console.log('Content is cached for offline use.'); - } - } - }; - }; - }) - .catch(error => { - console.error('Error during service worker registration:', error); - }); -} - -function checkValidServiceWorker(swUrl) { - // Check if the service worker can be found. If it can't reload the page. - fetch(swUrl) - .then(response => { - // Ensure service worker exists, and that we really are getting a JS file. - if ( - response.status === 404 || - response.headers.get('content-type').indexOf('javascript') === -1 - ) { - // No service worker found. Probably a different app. Reload the page. - navigator.serviceWorker.ready.then(registration => { - registration.unregister().then(() => { - window.location.reload(); - }); - }); - } else { - // Service worker found. Proceed as normal. - registerValidSW(swUrl); - } - }) - .catch(() => { - console.log( - 'No internet connection found. App is running in offline mode.' - ); - }); -} - -export function unregister() { - if ('serviceWorker' in navigator) { - navigator.serviceWorker.ready.then(registration => { - registration.unregister(); - }); - } -} diff --git a/src/ProjectTemplates/Web.Spa.ProjectTemplates/content/ReactRedux-CSharp/ClientApp/src/registerServiceWorker.ts b/src/ProjectTemplates/Web.Spa.ProjectTemplates/content/ReactRedux-CSharp/ClientApp/src/registerServiceWorker.ts new file mode 100644 index 0000000000..c0452d7086 --- /dev/null +++ b/src/ProjectTemplates/Web.Spa.ProjectTemplates/content/ReactRedux-CSharp/ClientApp/src/registerServiceWorker.ts @@ -0,0 +1,105 @@ +// In production, we register a service worker to serve assets from local cache. + +// This lets the app load faster on subsequent visits in production, and gives +// it offline capabilities. However, it also means that developers (and users) +// will only see deployed updates on the "N+1" visit to a page, since previously +// cached resources are updated in the background. + +// To learn more about the benefits of this model, read https://goo.gl/KwvDNy. +// This link also includes instructions on opting out of this behavior. + +const isLocalhost = Boolean( + window.location.hostname === 'localhost' || + // [::1] is the IPv6 localhost address. + window.location.hostname === '[::1]' || + // 127.0.0.1/8 is considered localhost for IPv4. + window.location.hostname.match( + /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/ + ) +); + +export default function register() { + if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) { + // The URL constructor is available in all browsers that support SW. + const url = process.env.PUBLIC_URL as string; + const publicUrl = new URL(url, window.location.toString()); + if (publicUrl.origin !== window.location.origin) { + // Our service worker won't work if PUBLIC_URL is on a different origin + // from what our page is served on. This might happen if a CDN is used to + // serve assets; see https://github.com/facebookincubator/create-react-app/issues/2374 + return; + } + + window.addEventListener('load', () => { + const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`; + + if (isLocalhost) { + // This is running on localhost. Lets check if a service worker still exists or not. + checkValidServiceWorker(swUrl); + } else { + // Is not local host. Just register service worker + registerValidSW(swUrl); + } + }); + } +} + +function registerValidSW(swUrl: string) { + navigator.serviceWorker + .register(swUrl) + .then(registration => { + registration.onupdatefound = () => { + const installingWorker = registration.installing as ServiceWorker; + installingWorker.onstatechange = () => { + if (installingWorker.state === 'installed') { + if (navigator.serviceWorker.controller) { + // At this point, the old content will have been purged and + // the fresh content will have been added to the cache. + // It's the perfect time to display a "New content is + // available; please refresh." message in your web app. + console.log('New content is available; please refresh.'); + } else { + // At this point, everything has been precached. + // It's the perfect time to display a + // "Content is cached for offline use." message. + console.log('Content is cached for offline use.'); + } + } + }; + }; + }) + .catch(error => { + console.error('Error during service worker registration:', error); + }); +} + +function checkValidServiceWorker(swUrl: string) { + // Check if the service worker can be found. If it can't reload the page. + fetch(swUrl) + .then(response => { + // Ensure service worker exists, and that we really are getting a JS file. + const contentType = response.headers.get('content-type'); + if (response.status === 404 || (contentType && contentType.indexOf('javascript') === -1)) { + // No service worker found. Probably a different app. Reload the page. + navigator.serviceWorker.ready.then(registration => { + registration.unregister().then(() => { + window.location.reload(); + }); + }); + } else { + // Service worker found. Proceed as normal. + registerValidSW(swUrl); + } + }) + .catch(() => { + console.log('No internet connection found. App is running in offline mode.'); + }); +} + +export function unregister() { + if ('serviceWorker' in navigator) { + navigator.serviceWorker.ready.then(registration => { + registration.unregister(); + }); + } +} diff --git a/src/ProjectTemplates/Web.Spa.ProjectTemplates/content/ReactRedux-CSharp/ClientApp/src/store/Counter.js b/src/ProjectTemplates/Web.Spa.ProjectTemplates/content/ReactRedux-CSharp/ClientApp/src/store/Counter.js deleted file mode 100644 index c3b7b101a7..0000000000 --- a/src/ProjectTemplates/Web.Spa.ProjectTemplates/content/ReactRedux-CSharp/ClientApp/src/store/Counter.js +++ /dev/null @@ -1,22 +0,0 @@ -const incrementCountType = 'INCREMENT_COUNT'; -const decrementCountType = 'DECREMENT_COUNT'; -const initialState = { count: 0 }; - -export const actionCreators = { - increment: () => ({ type: incrementCountType }), - decrement: () => ({ type: decrementCountType }) -}; - -export const reducer = (state, action) => { - state = state || initialState; - - if (action.type === incrementCountType) { - return { ...state, count: state.count + 1 }; - } - - if (action.type === decrementCountType) { - return { ...state, count: state.count - 1 }; - } - - return state; -}; diff --git a/src/ProjectTemplates/Web.Spa.ProjectTemplates/content/ReactRedux-CSharp/ClientApp/src/store/Counter.ts b/src/ProjectTemplates/Web.Spa.ProjectTemplates/content/ReactRedux-CSharp/ClientApp/src/store/Counter.ts new file mode 100644 index 0000000000..8b9a29989d --- /dev/null +++ b/src/ProjectTemplates/Web.Spa.ProjectTemplates/content/ReactRedux-CSharp/ClientApp/src/store/Counter.ts @@ -0,0 +1,48 @@ +import { Action, Reducer } from 'redux'; + +// ----------------- +// STATE - This defines the type of data maintained in the Redux store. + +export interface CounterState { + count: number; +} + +// ----------------- +// 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. + +export interface IncrementCountAction { type: 'INCREMENT_COUNT' } +export interface DecrementCountAction { type: 'DECREMENT_COUNT' } + +// Declare a 'discriminated union' type. This guarantees that all references to 'type' properties contain one of the +// declared type strings (and not any other arbitrary string). +export type KnownAction = IncrementCountAction | DecrementCountAction; + +// ---------------- +// 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 = { + increment: () => { type: 'INCREMENT_COUNT' }, + decrement: () => { type: 'DECREMENT_COUNT' } +}; + +// ---------------- +// REDUCER - For a given state and action, returns the new state. To support time travel, this must not mutate the old state. + +export const reducer: Reducer = (state: CounterState | undefined, incomingAction: Action): CounterState => { + if (state === undefined) { + return { count: 0 }; + } + + const action = incomingAction as KnownAction; + switch (action.type) { + case 'INCREMENT_COUNT': + return { count: state.count + 1 }; + case 'DECREMENT_COUNT': + return { count: state.count - 1 }; + default: + return state; + } +}; diff --git a/src/ProjectTemplates/Web.Spa.ProjectTemplates/content/ReactRedux-CSharp/ClientApp/src/store/WeatherForecasts.js b/src/ProjectTemplates/Web.Spa.ProjectTemplates/content/ReactRedux-CSharp/ClientApp/src/store/WeatherForecasts.js deleted file mode 100644 index 6f0049030f..0000000000 --- a/src/ProjectTemplates/Web.Spa.ProjectTemplates/content/ReactRedux-CSharp/ClientApp/src/store/WeatherForecasts.js +++ /dev/null @@ -1,43 +0,0 @@ -const requestWeatherForecastsType = 'REQUEST_WEATHER_FORECASTS'; -const receiveWeatherForecastsType = 'RECEIVE_WEATHER_FORECASTS'; -const initialState = { forecasts: [], isLoading: false }; - -export const actionCreators = { - requestWeatherForecasts: startDateIndex => async (dispatch, getState) => { - if (startDateIndex === getState().weatherForecasts.startDateIndex) { - // Don't issue a duplicate request (we already have or are loading the requested data) - return; - } - - dispatch({ type: requestWeatherForecastsType, startDateIndex }); - - const url = `api/SampleData/WeatherForecasts?startDateIndex=${startDateIndex}`; - const response = await fetch(url); - const forecasts = await response.json(); - - dispatch({ type: receiveWeatherForecastsType, startDateIndex, forecasts }); - } -}; - -export const reducer = (state, action) => { - state = state || initialState; - - if (action.type === requestWeatherForecastsType) { - return { - ...state, - startDateIndex: action.startDateIndex, - isLoading: true - }; - } - - if (action.type === receiveWeatherForecastsType) { - return { - ...state, - startDateIndex: action.startDateIndex, - forecasts: action.forecasts, - isLoading: false - }; - } - - return state; -}; diff --git a/src/ProjectTemplates/Web.Spa.ProjectTemplates/content/ReactRedux-CSharp/ClientApp/src/store/WeatherForecasts.ts b/src/ProjectTemplates/Web.Spa.ProjectTemplates/content/ReactRedux-CSharp/ClientApp/src/store/WeatherForecasts.ts new file mode 100644 index 0000000000..e43f996075 --- /dev/null +++ b/src/ProjectTemplates/Web.Spa.ProjectTemplates/content/ReactRedux-CSharp/ClientApp/src/store/WeatherForecasts.ts @@ -0,0 +1,91 @@ +import { Action, Reducer } from 'redux'; +import { AppThunkAction } from './'; + +// ----------------- +// STATE - This defines the type of data maintained in the Redux store. + +export interface WeatherForecastsState { + isLoading: boolean; + startDateIndex?: number; + forecasts: WeatherForecast[]; +} + +export interface WeatherForecast { + dateFormatted: string; + temperatureC: number; + temperatureF: number; + summary: 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. + +interface RequestWeatherForecastsAction { + type: 'REQUEST_WEATHER_FORECASTS'; + startDateIndex: number; +} + +interface ReceiveWeatherForecastsAction { + type: 'RECEIVE_WEATHER_FORECASTS'; + startDateIndex: number; + forecasts: WeatherForecast[]; +} + +// Declare a 'discriminated union' type. This guarantees that all references to 'type' properties contain one of the +// declared type strings (and not any other arbitrary string). +type KnownAction = RequestWeatherForecastsAction | ReceiveWeatherForecastsAction; + +// ---------------- +// 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 = { + requestWeatherForecasts: (startDateIndex: number): AppThunkAction => (dispatch, getState) => { + // Only load data if it's something we don't already have (and are not already loading) + const appState = getState(); + if (appState && appState.weatherForecasts && startDateIndex !== appState.weatherForecasts.startDateIndex) { + fetch(`api/SampleData/WeatherForecasts?startDateIndex=${startDateIndex}`) + .then(response => response.json() as Promise) + .then(data => { + dispatch({ type: 'RECEIVE_WEATHER_FORECASTS', startDateIndex: startDateIndex, forecasts: data }); + }); + + dispatch({ type: 'REQUEST_WEATHER_FORECASTS', startDateIndex: startDateIndex }); + } + } +}; + +// ---------------- +// REDUCER - For a given state and action, returns the new state. To support time travel, this must not mutate the old state. + +const unloadedState: WeatherForecastsState = { forecasts: [], isLoading: false }; + +export const reducer: Reducer = (state: WeatherForecastsState | undefined, incomingAction: Action): WeatherForecastsState => { + if (state === undefined) { + return unloadedState; + } + + const action = incomingAction as KnownAction; + switch (action.type) { + case 'REQUEST_WEATHER_FORECASTS': + return { + startDateIndex: action.startDateIndex, + forecasts: state.forecasts, + isLoading: true + }; + case 'RECEIVE_WEATHER_FORECASTS': + // Only accept the incoming data if it matches the most recent request. This ensures we correctly + // handle out-of-order responses. + if (action.startDateIndex === state.startDateIndex) { + return { + startDateIndex: action.startDateIndex, + forecasts: action.forecasts, + isLoading: false + }; + } + break; + } + + return state; +}; diff --git a/src/ProjectTemplates/Web.Spa.ProjectTemplates/content/ReactRedux-CSharp/ClientApp/src/store/configureStore.js b/src/ProjectTemplates/Web.Spa.ProjectTemplates/content/ReactRedux-CSharp/ClientApp/src/store/configureStore.js deleted file mode 100644 index 7da6766bda..0000000000 --- a/src/ProjectTemplates/Web.Spa.ProjectTemplates/content/ReactRedux-CSharp/ClientApp/src/store/configureStore.js +++ /dev/null @@ -1,35 +0,0 @@ -import { applyMiddleware, combineReducers, compose, createStore } from 'redux'; -import thunk from 'redux-thunk'; -import { routerReducer, routerMiddleware } from 'react-router-redux'; -import * as Counter from './Counter'; -import * as WeatherForecasts from './WeatherForecasts'; - -export default function configureStore (history, initialState) { - const reducers = { - counter: Counter.reducer, - weatherForecasts: WeatherForecasts.reducer - }; - - const middleware = [ - thunk, - routerMiddleware(history) - ]; - - // In development, use the browser's Redux dev tools extension if installed - const enhancers = []; - const isDevelopment = process.env.NODE_ENV === 'development'; - if (isDevelopment && typeof window !== 'undefined' && window.devToolsExtension) { - enhancers.push(window.devToolsExtension()); - } - - const rootReducer = combineReducers({ - ...reducers, - routing: routerReducer - }); - - return createStore( - rootReducer, - initialState, - compose(applyMiddleware(...middleware), ...enhancers) - ); -} diff --git a/src/ProjectTemplates/Web.Spa.ProjectTemplates/content/ReactRedux-CSharp/ClientApp/src/store/configureStore.ts b/src/ProjectTemplates/Web.Spa.ProjectTemplates/content/ReactRedux-CSharp/ClientApp/src/store/configureStore.ts new file mode 100644 index 0000000000..06928a6241 --- /dev/null +++ b/src/ProjectTemplates/Web.Spa.ProjectTemplates/content/ReactRedux-CSharp/ClientApp/src/store/configureStore.ts @@ -0,0 +1,29 @@ +import { applyMiddleware, combineReducers, compose, createStore, Reducer } from 'redux'; +import thunk from 'redux-thunk'; +import { connectRouter, routerMiddleware } from 'connected-react-router'; +import { History } from 'history'; +import { ApplicationState, reducers } from './'; + +export default function configureStore(history: History, initialState?: ApplicationState) { + const middleware = [ + thunk, + routerMiddleware(history) + ]; + + const rootReducer = combineReducers({ + ...reducers, + router: connectRouter(history) + }); + + const enhancers = []; + const windowIfDefined = typeof window === 'undefined' ? null : window as any; + if (windowIfDefined && windowIfDefined.__REDUX_DEVTOOLS_EXTENSION__) { + enhancers.push(windowIfDefined.__REDUX_DEVTOOLS_EXTENSION__()); + } + + return createStore( + rootReducer, + initialState, + compose(applyMiddleware(...middleware), ...enhancers) + ); +} diff --git a/src/ProjectTemplates/Web.Spa.ProjectTemplates/content/ReactRedux-CSharp/ClientApp/src/store/index.ts b/src/ProjectTemplates/Web.Spa.ProjectTemplates/content/ReactRedux-CSharp/ClientApp/src/store/index.ts new file mode 100644 index 0000000000..a5f7c05b39 --- /dev/null +++ b/src/ProjectTemplates/Web.Spa.ProjectTemplates/content/ReactRedux-CSharp/ClientApp/src/store/index.ts @@ -0,0 +1,22 @@ +import * as WeatherForecasts from './WeatherForecasts'; +import * as Counter from './Counter'; + +// The top-level state object +export interface ApplicationState { + counter: Counter.CounterState | undefined; + weatherForecasts: WeatherForecasts.WeatherForecastsState | undefined; +} + +// 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 = { + counter: Counter.reducer, + weatherForecasts: WeatherForecasts.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 interface AppThunkAction { + (dispatch: (action: TAction) => void, getState: () => ApplicationState): void; +} diff --git a/src/ProjectTemplates/Web.Spa.ProjectTemplates/content/ReactRedux-CSharp/ClientApp/tsconfig.json b/src/ProjectTemplates/Web.Spa.ProjectTemplates/content/ReactRedux-CSharp/ClientApp/tsconfig.json new file mode 100644 index 0000000000..8a6b4c16b4 --- /dev/null +++ b/src/ProjectTemplates/Web.Spa.ProjectTemplates/content/ReactRedux-CSharp/ClientApp/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "target": "es5", + "allowJs": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "module": "esnext", + "moduleResolution": "node", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "preserve", + "lib": [ + "es2015", + "dom" + ], + "skipLibCheck": false + }, + "include": [ + "src" + ] +}