diff --git a/17 Observables/package.json b/17 Observables/package.json new file mode 100644 index 0000000..5816863 --- /dev/null +++ b/17 Observables/package.json @@ -0,0 +1,42 @@ +{ + "name": "samplereact", + "version": "1.0.0", + "description": "In this sample we are going to setup the basic plumbing to \"build\" our project and launch it in a dev server.", + "main": "index.js", + "scripts": { + "webpack": "webpack", + "start": "webpack-dev-server --inline", + "test": "echo \"Error: no test specified\" && exit 1" + }, + "author": "", + "license": "ISC", + "devDependencies": { + "@types/es6-shim": "^0.31.32", + "@types/rx": "^2.5.34", + "redux-observable": "^0.12.2", + "rx": "^4.1.0", + "rxjs": "^5.0.1", + "typescript": "^2.0.3", + "webpack": "^1.13.2", + "webpack-dev-server": "^1.16.2" + }, + "dependencies": { + "@types/object-assign": "^4.0.30", + "@types/react": "^0.14.43", + "@types/react-dom": "^0.14.18", + "@types/react-redux": "^4.4.32", + "@types/redux": "^3.6.31", + "bootstrap": "^3.3.7", + "css-loader": "^0.25.0", + "file-loader": "^0.9.0", + "html-webpack-plugin": "^2.22.0", + "object-assign": "^4.1.0", + "react": "^15.3.2", + "react-dom": "^15.3.2", + "react-redux": "^4.4.5", + "redux": "^3.6.0", + "style-loader": "^0.13.1", + "ts-loader": "^0.9.3", + "url-loader": "^0.5.7" + } +} diff --git a/17 Observables/readme.md b/17 Observables/readme.md new file mode 100644 index 0000000..d223945 --- /dev/null +++ b/17 Observables/readme.md @@ -0,0 +1,639 @@ +# 16 Observables + +This sample takes as starting point _04 Refactor_ + +Let's play with async calls and epic middleware (redux observables). + +In this sample we are going to display a table, the data will +be retrieve from github api. + +Summary steps: + +- Let's install the needed package and typescript definitions. +- Let's register our Middleware. +- Let's create a rest api class to access this data. +- Let's define two new actions. +- Let's create an action that will trigger and async action. +- Let's add a new reducer that will hold members state. +- Let's create a memberRow component. +- Let's create a memberTable component. +- Let's create a memberArea component (include a load button). +- Let's create a memberAreaContainer. + +Additional step: + +- Let's define two new actions (ajax-with-delay and cancel). + +# Prerequisites + +Install [Node.js and npm](https://nodejs.org/en/) (v6.6.0 or newer) if they are not already installed on your computer. + +> Verify that you are running at least node v6.x.x and npm 3.x.x by running `node -v` and `npm -v` in a terminal/console window. Older versions may produce errors. + +## Steps to build it + +- Copy the content from _04 Refactor_ and execute: + + ```bash + npm install + ``` + +- We have to install libraries and typescript definitions to handle fetch calls: redux-observable, rx and rxjs + + ```bash + npm install --save-dev redux-observable rx rxjs @types/rx @types/es6-shim + ``` + +- Let's register a Redux Epic Middleware in _./src/store.ts_ + + ```javascript + import { createStore, applyMiddleware, compose } from "redux"; + import { createEpicMiddleware } from "redux-observable"; + import { rootEpic } from "./epics"; + import { reducers } from "./reducers/"; + + const epicMiddleware = createEpicMiddleware(rootEpic); + + export const store = createStore( + reducers, + compose( + applyMiddleware(epicMiddleware), + ), + ); + + ``` + +- And use the store in _./src/main.tsx_ + + ```jsx + import * as React from "react"; + import * as ReactDOM from "react-dom"; + import { Provider } from "react-redux"; + import { App } from "./app"; + import { store } from "./store"; + + ReactDOM.render( + + + , + document.getElementById("root") + ); + + ``` + +- Let's create an entity under _./src/model/member.ts_ + + ```javascript + export class MemberEntity { + id: number; + login: string; + avatar_url: string; + + constructor() { + this.id = -1; + this.login = ""; + this.avatar_url = ""; + } + } + ``` + +- Let's create an epic to access this data, under _./src/epics/fetchMembersEpic.ts_ + + ```javascript + import 'rxjs'; + + // merge all actions in only one action + import { } from "rxjs/add/operator/mergeMap"; + + // map to throw a new action + import { } from "rxjs/add/operator/map"; + + import { actionsEnums } from "../common/actionsEnums"; + import { memberRequestCompleted } from "../actions/"; + import { memberAPI } from "../restApi/memberApi"; + + // the dollar symbol in the action$ param is just a convention + export const fetchMembersEpic = action$ => + // action param is not necesary, but it will be useful + // to better understand the code + action$.ofType(actionsEnums.MEMBER_REQUEST_STARTED).mergeMap(action => + memberAPI.getAllMembers() + // memberRequestCompleted will be only an action ({type: '...', ...}) + // without "black magic" for promises + .map(memberRequestCompleted) + ); + + ``` + + _./src/epics/index.ts_ + + ```javascript + import { combineEpics } from "redux-observable"; + + import { fetchMembersEpic } from "./fetchMembersEpic"; + + export const rootEpic = combineEpics(fetchMembersEpic); + ``` + +- Let's create a rest api class to access this data with rxjs-observable-ajax, under _./src/restApi/memberApi.ts_ + + ```javascript + import { ajax } from "rxjs/observable/dom/ajax"; + + // Sync mock data API, inspired from: + // https://gist.github.com/coryhouse/fd6232f95f9d601158e4 + class MemberAPI { + getAllMembers() { + return ( + ajax.getJSON("https://api.github.com/orgs/lemoncode/members") + ); + } + } + + export const memberAPI = new MemberAPI(); + ``` + +- Create _./src/actions/memberRequest.ts_ + + ```javascript + import { actionsEnums } from "../common/actionsEnums"; + + export const memberRequest = () => { + return { + type: actionsEnums.MEMBER_REQUEST_STARTED, + }; + }; + ``` + +- It's time to define two new actions _./src/common/actionsEnums.ts_ + + ```javascript + export const actionsEnums = { + UPDATE_USERPROFILE_NAME: "UPDATE_USERPROFILE_NAME", + UPDATE_USERPROFILE_FAVOURITE_COLOR: "UPDATE_USERPROFILE_FAVOURITE_COLOR", + MEMBER_REQUEST_STARTED: "MEMBER_REQUEST_STARTED", + MEMBER_REQUEST_COMPLETED: "MEMBER_REQUEST_COMPLETED", + }; + + ``` + +- Let's create an *simple* action that will inform members once completed in _./src/actions/memberRequestCompleted.ts_ + + ```javascript + import { actionsEnums } from "../common/actionsEnums"; + import { MemberEntity } from "../model/member"; + + export const memberRequestCompleted = (members: MemberEntity[]) => { + // without "black magic" promises! MUAHAHAHAH! + return { + type: actionsEnums.MEMBER_REQUEST_COMPLETED, + members: members, + }; + }; + + ``` + +- And _./src/actions/index.ts_ to use easily: + + ```javascript + import { memberRequestCompleted } from "./memberRequestCompleted"; + import { memberRequest } from "./memberRequest"; + + export { memberRequest, memberRequestCompleted }; + ``` + +- Let's add a new reducer that will hold members state + + _./src/reducers/memberReducer.ts_ + + ```javascript + import { actionsEnums } from "../common/actionsEnums"; + import { MemberEntity } from "../model/member"; + import objectAssign = require("object-assign"); + + class memberState { + members: MemberEntity[]; + + public constructor() { + this.members = []; + } + } + + export const memberReducer = (state: memberState = new memberState(), action) => { + switch (action.type) { + case actionsEnums.MEMBER_REQUEST_COMPLETED: + return handleMemberRequestCompletedAction(state, action); + } + + return state; + }; + + + const handleMemberRequestCompletedAction = (state: memberState, action) => { + const newState = objectAssign({}, state, {members: action.members}); + return newState; + } + + ``` + +- Let's register it _./src/reducers/index.ts_ + + ```javascript + import { combineReducers } from 'redux'; + import { userProfileReducer } from './userProfile'; + import { memberReducer } from './memberReducer'; + + export const reducers = combineReducers({ + userProfileReducer, + memberReducer, + }); + ``` + +- Let's create a `memberRow` component _./src/components/members/memberRow.tsx_ + + ```jsx + import * as React from "react"; + import { MemberEntity } from "../../model/member"; + + interface Props { + member: MemberEntity; + } + + export const MemberRow = (props: Props) => { + return ( + + + + + + {props.member.id} + + + {props.member.login} + + + ); + } + + ``` + +- Let's create a memberTable component under _./src/components/members/memberTable.tsx_ + + ```jsx + import * as React from "react"; + import { MemberEntity } from "../../model/member"; + import { MemberRow } from "./memberRow"; + + interface Props { + members: MemberEntity[]; + } + + export const MembersTable = (props: Props) => { + return ( +
+

Members Page

+ + + + + + + + + + { + props.members.map((member) => + + ) + } + +
+ Avatar + + Id + + Name +
+
+ ); + } + + ``` + +- Let's create a memberArea component (include a load button) in _./src/components/members/memberArea.tsx_ + + ```jsx + import * as React from 'react'; + import { MembersTable } from './memberTable'; + import { MemberEntity } from '../../model/member' + + interface Props { + loadMembers: () => any; + members: Array; + } + + export class MembersArea extends React.Component { + constructor(props: Props){ + super(props); + + this.state = {members:[]}; + } + + render(){ + return ( +
+ +
+ this.props.loadMembers()} + /> +
+ ); + } + } + + ``` + +- Let's create a memberAreaContainer. + + _./src/components/members/memberAreaContainer.ts_ + + ```javascript + import { connect } from "react-redux"; + import { memberRequest } from "../../actions/memberRequest"; + import { MembersArea } from "./memberArea"; + + const mapStateToProps = (state) => { + return { + members: state.memberReducer.members + }; + } + + const mapDispatchToProps = (dispatch) => { + return { + loadMembers: () => {return dispatch(memberRequest())} + }; + } + + export const MembersAreaContainer = connect( + mapStateToProps, + mapDispatchToProps, + )(MembersArea) + + ``` + +- Let's create an _./src/components/members/index.ts_ + + ```javascript + import { MembersAreaContainer } from './memberAreaContainer'; + + export { + MembersAreaContainer + } + + ``` + +- Let's instantiate it on _app.tsx_ + + ```jsx + import * as React from "react"; + import { MembersAreaContainer } from './components/members'; + import { HelloWorldContainer } from "./components/helloworld"; + import { NameEditContainer } from "./components/nameEdit"; + import { ColorDisplayerContainer } from "./components/color"; + import { ColorPickerContainer } from "./components/color"; + + export const App = () => { + return ( +
+ +
+ +
+ +
+ +
+ +
+ ); + }; + + ``` + +- Let's give a try. + + ```shell + npm start + ``` + +## Additional step + +- We will add DevToolsExtensions support to see better the behavior: + + _./src/store.ts_ + + ```javascript + // ... + reducers, + compose( + applyMiddleware(epicMiddleware), + window['devToolsExtension'] ? window['devToolsExtension']() : f => f + ), + ); + ``` + +- We will add a delay in the ajax request and cancel behavior: + + _./src/common/actionsEnums.ts_ + + ```javascript + export const actionsEnums = { + // ... + MEMBER_REQUEST_CANCELLED: "MEMBER_REQUEST_CANCELLED", + }; + ``` + + _./src/actions/memberRequestCancelled.ts_ + + ```javascript + import { actionsEnums } from "../common/actionsEnums"; + + export const memberRequestCancelled = () => { + return { + type: actionsEnums.MEMBER_REQUEST_CANCELLED, + }; + }; + ``` + + _./src/actions/index.ts_ + + ```javascript + import { memberRequestCompleted } from "./memberRequestCompleted"; + import { memberRequest } from "./memberRequest"; + import { memberRequestCancelled } from "./memberRequestCancelled"; + + export { memberRequest, memberRequestCompleted, memberRequestCancelled }; + ``` + + _./src/epics/fetchMembersEpic.ts_ + + ```javascript + // ... + + // delay the getAllMembers ajax petition + import { } from "rxjs/add/operator/delay"; + + // ... + export const fetchMembersEpic = action$ => + // ... + action$.ofType(actionsEnums.MEMBER_REQUEST_STARTED).mergeMap(action => + memberAPI.getAllMembers() + // Asynchronously wait 2000ms then continue + .delay(2000) + // memberRequestCompleted will be only an action ({type: '...', ...}) + // without "black magic" for promises + .map(memberRequestCompleted) + .takeUntil(action$.ofType(actionsEnums.MEMBER_REQUEST_CANCELLED)) + ); + + ``` + +- We add a button to cancel the `MEMBER_REQUEST_STARTED` action and a members_loading indicator in state: + + _./src/components/members/memberAreaContainer.ts_ + + ```javascript + import { connect } from "react-redux"; + import { memberRequest, memberRequestCancelled } from "../../actions/"; + import { MembersArea } from "./memberArea"; + + const mapStateToProps = (state) => { + return { + members: state.memberReducer.members, + members_loading: state.memberReducer.members_loading, + }; + } + + const mapDispatchToProps = (dispatch) => { + return { + loadMembers: () => {return dispatch(memberRequest())}, + cancelLoadMembers: () => {return dispatch(memberRequestCancelled())}, + }; + } + + export const MembersAreaContainer = connect( + mapStateToProps, + mapDispatchToProps, + )(MembersArea) + ``` + + _./src/components/members/memberArea.tsx_ + + ```jsx + // ... + + interface Props { + loadMembers: () => any; + cancelLoadMembers: () => any; + members: Array; + members_loading: boolean; + } + + export class MembersArea extends React.Component { + // ... + + render(){ + return ( +
+ +
+
+ + { + this.props.members_loading ? + + : + '' + } +
+
+ ); + } + } + + ``` + +- and new cases in the `memberReducer` to handle the cancel an loading_members indicator: + + _./src/reducers/memberReducer.ts_ + + ```javascript + import { actionsEnums } from "../common/actionsEnums"; + import { MemberEntity } from "../model/member"; + import objectAssign = require("object-assign"); + + class memberState { + members: MemberEntity[]; + members_loading: boolean; + + public constructor() { + this.members = []; + this.members_loading = false; + } + } + + const handleMemberRequestCompletedAction = (state: memberState, action) => { + const newState = objectAssign({}, state, { members_loading: false, members: action.members }); + return newState; + } + + const handleMemberRequestStartedAction = (state: memberState, action) => { + const newState = objectAssign({}, state, { members_loading: true }); + return newState; + } + + const handleMemberRequestCancelledAction = (state: memberState, action) => { + const newState = objectAssign({}, state, { members_loading: false }); + return newState; + } + + export const memberReducer = (state: memberState = new memberState(), action) => { + switch (action.type) { + case actionsEnums.MEMBER_REQUEST_STARTED: + return handleMemberRequestStartedAction(state, action); + case actionsEnums.MEMBER_REQUEST_COMPLETED: + return handleMemberRequestCompletedAction(state, action); + case actionsEnums.MEMBER_REQUEST_CANCELLED: + return handleMemberRequestCancelledAction(state, action); + } + + return state; + }; + + ``` + +- Let's give a try again. We should can cancel the loading of members with a new button. + + ```shell + npm start + ``` diff --git a/17 Observables/src/actions/index.ts b/17 Observables/src/actions/index.ts new file mode 100644 index 0000000..95891c2 --- /dev/null +++ b/17 Observables/src/actions/index.ts @@ -0,0 +1,5 @@ +import { memberRequestCompleted } from "./memberRequestCompleted"; +import { memberRequest } from "./memberRequest"; +import { memberRequestCancelled } from "./memberRequestCancelled"; + +export { memberRequest, memberRequestCompleted, memberRequestCancelled }; diff --git a/17 Observables/src/actions/memberRequest.ts b/17 Observables/src/actions/memberRequest.ts new file mode 100644 index 0000000..bd068b7 --- /dev/null +++ b/17 Observables/src/actions/memberRequest.ts @@ -0,0 +1,7 @@ +import { actionsEnums } from "../common/actionsEnums"; + +export const memberRequest = () => { + return { + type: actionsEnums.MEMBER_REQUEST_STARTED, + }; +}; diff --git a/17 Observables/src/actions/memberRequestCancelled.ts b/17 Observables/src/actions/memberRequestCancelled.ts new file mode 100644 index 0000000..eb69b28 --- /dev/null +++ b/17 Observables/src/actions/memberRequestCancelled.ts @@ -0,0 +1,7 @@ +import { actionsEnums } from "../common/actionsEnums"; + +export const memberRequestCancelled = () => { + return { + type: actionsEnums.MEMBER_REQUEST_CANCELLED, + }; +}; diff --git a/17 Observables/src/actions/memberRequestCompleted.ts b/17 Observables/src/actions/memberRequestCompleted.ts new file mode 100644 index 0000000..0e7d96a --- /dev/null +++ b/17 Observables/src/actions/memberRequestCompleted.ts @@ -0,0 +1,10 @@ +import { actionsEnums } from "../common/actionsEnums"; +import { MemberEntity } from "../model/member"; + +export const memberRequestCompleted = (members: MemberEntity[]) => { + // without "black magic" promises! MUAHAHAHAH! + return { + type: actionsEnums.MEMBER_REQUEST_COMPLETED, + members: members, + }; +}; diff --git a/17 Observables/src/actions/updateFavouriteColor.ts b/17 Observables/src/actions/updateFavouriteColor.ts new file mode 100644 index 0000000..1d1de12 --- /dev/null +++ b/17 Observables/src/actions/updateFavouriteColor.ts @@ -0,0 +1,9 @@ +import { actionsEnums } from "../common/actionsEnums"; +import { Color } from "../model/color"; + +export const updateFavouriteColor = (newColor: Color) => { + return { + type: actionsEnums.UPDATE_USERPROFILE_FAVOURITE_COLOR, + newColor: newColor, + }; +}; diff --git a/17 Observables/src/actions/updateUserProfileName.ts b/17 Observables/src/actions/updateUserProfileName.ts new file mode 100644 index 0000000..655ce30 --- /dev/null +++ b/17 Observables/src/actions/updateUserProfileName.ts @@ -0,0 +1,8 @@ +import {actionsEnums} from "../common/actionsEnums"; + +export const updateUserProfileName = (newName: string) => { + return { + type: actionsEnums.UPDATE_USERPROFILE_NAME, + newName: newName + }; +}; diff --git a/17 Observables/src/app.tsx b/17 Observables/src/app.tsx new file mode 100644 index 0000000..b35ac19 --- /dev/null +++ b/17 Observables/src/app.tsx @@ -0,0 +1,22 @@ +import * as React from "react"; +import { MembersAreaContainer } from './components/members'; +import { HelloWorldContainer } from "./components/helloworld"; +import { NameEditContainer } from "./components/nameEdit"; +import { ColorDisplayerContainer } from "./components/color"; +import { ColorPickerContainer } from "./components/color"; + +export const App = () => { + return ( +
+ +
+ +
+ +
+ +
+ +
+ ); +}; diff --git a/17 Observables/src/common/actionsEnums.ts b/17 Observables/src/common/actionsEnums.ts new file mode 100644 index 0000000..6147002 --- /dev/null +++ b/17 Observables/src/common/actionsEnums.ts @@ -0,0 +1,7 @@ +export const actionsEnums = { + UPDATE_USERPROFILE_NAME: "UPDATE_USERPROFILE_NAME", + UPDATE_USERPROFILE_FAVOURITE_COLOR: "UPDATE_USERPROFILE_FAVOURITE_COLOR", + MEMBER_REQUEST_STARTED: "MEMBER_REQUEST_STARTED", + MEMBER_REQUEST_COMPLETED: "MEMBER_REQUEST_COMPLETED", + MEMBER_REQUEST_CANCELLED: "MEMBER_REQUEST_CANCELLED", +}; diff --git a/17 Observables/src/components/color/colordisplayer.tsx b/17 Observables/src/components/color/colordisplayer.tsx new file mode 100644 index 0000000..f10b6aa --- /dev/null +++ b/17 Observables/src/components/color/colordisplayer.tsx @@ -0,0 +1,21 @@ +import * as React from "react"; +import { Color } from "../../model/color"; + +interface Props { + color: Color; +} + +export const ColorDisplayer = (props: Props) => { + // `rgb(${props.color.red},${props.color.green}, ${props.color.blue}) })` + // "rgb(" + props.color.red + ", 40, 80)" + let divStyle = { + width: "120px", + height: "80px", + backgroundColor: `rgb(${props.color.red},${props.color.green}, ${props.color.blue})` + }; + + return ( +
+
+ ); +}; diff --git a/17 Observables/src/components/color/colordisplayerContainer.ts b/17 Observables/src/components/color/colordisplayerContainer.ts new file mode 100644 index 0000000..52b401c --- /dev/null +++ b/17 Observables/src/components/color/colordisplayerContainer.ts @@ -0,0 +1,18 @@ +import { connect } from "react-redux"; +import { ColorDisplayer } from "./colordisplayer"; + +const mapStateToProps = (state) => { + return { + color: state.userProfileReducer.favouriteColor + }; +}; + +const mapDispatchToProps = (dispatch) => { + return { + }; +}; + +export const ColorDisplayerContainer = connect( + mapStateToProps, + mapDispatchToProps +)(ColorDisplayer); diff --git a/17 Observables/src/components/color/colorpicker.tsx b/17 Observables/src/components/color/colorpicker.tsx new file mode 100644 index 0000000..637d32d --- /dev/null +++ b/17 Observables/src/components/color/colorpicker.tsx @@ -0,0 +1,32 @@ +import * as React from "react"; +import { Color } from "../../model/color"; +import { ColorSlider } from "./colorslider"; + +interface Props { + color: Color; + onColorUpdated: (color: Color) => void; +} + +export const ColorPicker = (props: Props) => { + return ( +
+ props.onColorUpdated( + {red: value, green: props.color.green, blue: props.color.blue}) } + /> +
+ props.onColorUpdated( + {red: props.color.red, green: value, blue: props.color.blue}) } + /> +
+ props.onColorUpdated( + {red: props.color.red, green: props.color.green, blue: value}) } + /> +
+ ); +}; diff --git a/17 Observables/src/components/color/colorpickerContainer.ts b/17 Observables/src/components/color/colorpickerContainer.ts new file mode 100644 index 0000000..0e2867a --- /dev/null +++ b/17 Observables/src/components/color/colorpickerContainer.ts @@ -0,0 +1,23 @@ +import { connect } from "react-redux"; +import { Color } from "../../model/color"; +import { ColorPicker } from "./colorpicker"; +import { updateFavouriteColor } from "../../actions/updateFavouriteColor"; + +const mapStateToProps = (state) => { + return { + color: state.userProfileReducer.favouriteColor + }; +}; + +const mapDispatchToProps = (dispatch) => { + return { + onColorUpdated: (color: Color) => { + return dispatch(updateFavouriteColor(color)); + } + }; +}; + +export const ColorPickerContainer = connect( + mapStateToProps, + mapDispatchToProps +)(ColorPicker); diff --git a/17 Observables/src/components/color/colorslider.tsx b/17 Observables/src/components/color/colorslider.tsx new file mode 100644 index 0000000..a0aea65 --- /dev/null +++ b/17 Observables/src/components/color/colorslider.tsx @@ -0,0 +1,22 @@ +import * as React from "react"; +import { Color } from "../../model/color"; + +interface Props { + value: number; + onValueUpdated: (newValue: number) => void; +} + +export const ColorSlider = (props: Props) => { + return ( +
+ props.onValueUpdated(event.target.value)} + /> + {props.value} +
+ ); +}; diff --git a/17 Observables/src/components/color/index.ts b/17 Observables/src/components/color/index.ts new file mode 100644 index 0000000..a960836 --- /dev/null +++ b/17 Observables/src/components/color/index.ts @@ -0,0 +1,7 @@ +import { ColorPickerContainer } from "./colorpickerContainer"; +import { ColorDisplayerContainer } from "./colordisplayerContainer"; + +export { + ColorPickerContainer, + ColorDisplayerContainer +} diff --git a/17 Observables/src/components/helloworld/helloWorld.tsx b/17 Observables/src/components/helloworld/helloWorld.tsx new file mode 100644 index 0000000..f443f9e --- /dev/null +++ b/17 Observables/src/components/helloworld/helloWorld.tsx @@ -0,0 +1,7 @@ +import * as React from "react"; + +export const HelloWorldComponent = (props: {userName: string}) => { + return ( +

Hello Mr. {props.userName} !

+ ); +}; diff --git a/17 Observables/src/components/helloworld/helloWorldContainer.ts b/17 Observables/src/components/helloworld/helloWorldContainer.ts new file mode 100644 index 0000000..bca5ebb --- /dev/null +++ b/17 Observables/src/components/helloworld/helloWorldContainer.ts @@ -0,0 +1,18 @@ +import { connect } from "react-redux"; +import { HelloWorldComponent } from "./helloWorld"; + +const mapStateToProps = (state) => { + return { + userName: state.userProfileReducer.firstname + }; +}; + +const mapDispatchToProps = (dispatch) => { + return { + }; +}; + +export const HelloWorldContainer = connect( + mapStateToProps, + mapDispatchToProps +)(HelloWorldComponent); diff --git a/17 Observables/src/components/helloworld/index.ts b/17 Observables/src/components/helloworld/index.ts new file mode 100644 index 0000000..ec408aa --- /dev/null +++ b/17 Observables/src/components/helloworld/index.ts @@ -0,0 +1,5 @@ +import { HelloWorldContainer } from "./helloWorldContainer"; + +export { + HelloWorldContainer +} diff --git a/17 Observables/src/components/members/index.ts b/17 Observables/src/components/members/index.ts new file mode 100644 index 0000000..13cb8e3 --- /dev/null +++ b/17 Observables/src/components/members/index.ts @@ -0,0 +1,5 @@ +import { MembersAreaContainer } from './memberAreaContainer'; + +export { + MembersAreaContainer +} diff --git a/17 Observables/src/components/members/memberArea.tsx b/17 Observables/src/components/members/memberArea.tsx new file mode 100644 index 0000000..1873017 --- /dev/null +++ b/17 Observables/src/components/members/memberArea.tsx @@ -0,0 +1,52 @@ +import * as React from 'react'; +import { MembersTable } from './memberTable'; +import { MemberEntity } from '../../model/member' + +interface Props { + loadMembers: () => any; + cancelLoadMembers: () => any; + members: Array; + members_loading: boolean; +} + +export class MembersArea extends React.Component { + constructor(props: Props){ + super(props); + + this.state = {members:[]}; + } + + render(){ + return ( +
+ +
+
+ + { + this.props.members_loading ? + + : + '' + } +
+
+ ); + } +} diff --git a/17 Observables/src/components/members/memberAreaContainer.ts b/17 Observables/src/components/members/memberAreaContainer.ts new file mode 100644 index 0000000..e2bc7f6 --- /dev/null +++ b/17 Observables/src/components/members/memberAreaContainer.ts @@ -0,0 +1,22 @@ +import { connect } from "react-redux"; +import { memberRequest, memberRequestCancelled } from "../../actions/"; +import { MembersArea } from "./memberArea"; + +const mapStateToProps = (state) => { + return { + members: state.memberReducer.members, + members_loading: state.memberReducer.members_loading, + }; +} + +const mapDispatchToProps = (dispatch) => { + return { + loadMembers: () => {return dispatch(memberRequest())}, + cancelLoadMembers: () => {return dispatch(memberRequestCancelled())}, + }; +} + +export const MembersAreaContainer = connect( + mapStateToProps, + mapDispatchToProps, +)(MembersArea) diff --git a/17 Observables/src/components/members/memberRow.tsx b/17 Observables/src/components/members/memberRow.tsx new file mode 100644 index 0000000..6db5a79 --- /dev/null +++ b/17 Observables/src/components/members/memberRow.tsx @@ -0,0 +1,22 @@ +import * as React from "react"; +import { MemberEntity } from "../../model/member"; + +interface Props { + member: MemberEntity; +} + +export const MemberRow = (props: Props) => { + return ( + + + + + + {props.member.id} + + + {props.member.login} + + + ); +} diff --git a/17 Observables/src/components/members/memberTable.tsx b/17 Observables/src/components/members/memberTable.tsx new file mode 100644 index 0000000..2141b4b --- /dev/null +++ b/17 Observables/src/components/members/memberTable.tsx @@ -0,0 +1,37 @@ +import * as React from "react"; +import { MemberEntity } from "../../model/member"; +import { MemberRow } from "./memberRow"; + +interface Props { + members: MemberEntity[]; +} + +export const MembersTable = (props: Props) => { + return ( +
+

Members Page

+ + + + + + + + + + { + props.members.map((member) => + + ) + } + +
+ Avatar + + Id + + Name +
+
+ ); +} diff --git a/17 Observables/src/components/nameEdit/index.ts b/17 Observables/src/components/nameEdit/index.ts new file mode 100644 index 0000000..096f772 --- /dev/null +++ b/17 Observables/src/components/nameEdit/index.ts @@ -0,0 +1,5 @@ +import { NameEditContainer } from "./nameEditContainer"; + +export { + NameEditContainer +} diff --git a/17 Observables/src/components/nameEdit/nameEdit.tsx b/17 Observables/src/components/nameEdit/nameEdit.tsx new file mode 100644 index 0000000..ce45916 --- /dev/null +++ b/17 Observables/src/components/nameEdit/nameEdit.tsx @@ -0,0 +1,13 @@ +import * as React from "react"; + +export const NameEditComponent = (props: {userName: string, onChange: (name: string) => any}) => { + return ( +
+ + props.onChange(e.target.value)} + /> +
+ ); +}; diff --git a/17 Observables/src/components/nameEdit/nameEditContainer.ts b/17 Observables/src/components/nameEdit/nameEditContainer.ts new file mode 100644 index 0000000..cfd1ab1 --- /dev/null +++ b/17 Observables/src/components/nameEdit/nameEditContainer.ts @@ -0,0 +1,22 @@ +import { connect } from "react-redux"; +import { NameEditComponent } from "./nameEdit"; +import { updateUserProfileName } from "../../actions/updateUserProfileName"; + +const mapStateToProps = (state) => { + return { + userName: state.userProfileReducer.firstname + }; +}; + +const mapDispatchToProps = (dispatch) => { + return { + onChange: (name: string) => { + return dispatch(updateUserProfileName(name)); + } + }; +}; + +export const NameEditContainer = connect( + mapStateToProps, + mapDispatchToProps +)(NameEditComponent); diff --git a/17 Observables/src/epics/fetchMembersEpic.ts b/17 Observables/src/epics/fetchMembersEpic.ts new file mode 100644 index 0000000..4bae8d5 --- /dev/null +++ b/17 Observables/src/epics/fetchMembersEpic.ts @@ -0,0 +1,29 @@ +import 'rxjs'; + + +// merge all actions in only one action +import { } from "rxjs/add/operator/mergeMap"; + +// map to throw a new action +import { } from "rxjs/add/operator/map"; + +// delay the getAllMembers ajax petition +import { } from "rxjs/add/operator/delay"; + +import { actionsEnums } from "../common/actionsEnums"; +import { memberRequestCompleted } from "../actions/"; +import { memberAPI } from "../restApi/memberApi"; + +// the dollar symbol in the action$ param is just a convention +export const fetchMembersEpic = action$ => + // action param is not necesary, but it will be useful + // to better understand the code + action$.ofType(actionsEnums.MEMBER_REQUEST_STARTED).mergeMap(action => + memberAPI.getAllMembers() + // Asynchronously wait 2000ms then continue + .delay(2000) + // memberRequestCompleted will be only an action ({type: '...', ...}) + // without "black magic" for promises + .map(memberRequestCompleted) + .takeUntil(action$.ofType(actionsEnums.MEMBER_REQUEST_CANCELLED)) + ); diff --git a/17 Observables/src/epics/index.ts b/17 Observables/src/epics/index.ts new file mode 100644 index 0000000..6288c0a --- /dev/null +++ b/17 Observables/src/epics/index.ts @@ -0,0 +1,5 @@ +import { combineEpics } from "redux-observable"; + +import { fetchMembersEpic } from "./fetchMembersEpic"; + +export const rootEpic = combineEpics(fetchMembersEpic); diff --git a/17 Observables/src/index.html b/17 Observables/src/index.html new file mode 100644 index 0000000..4b32a83 --- /dev/null +++ b/17 Observables/src/index.html @@ -0,0 +1,12 @@ + + + + + + + +

Sample app

+
+
+ + diff --git a/17 Observables/src/main.tsx b/17 Observables/src/main.tsx new file mode 100644 index 0000000..65d5e6b --- /dev/null +++ b/17 Observables/src/main.tsx @@ -0,0 +1,12 @@ +import * as React from "react"; +import * as ReactDOM from "react-dom"; +import { Provider } from "react-redux"; +import { App } from "./app"; +import { store } from "./store"; + +ReactDOM.render( + + + , + document.getElementById("root") +); diff --git a/17 Observables/src/model/color.ts b/17 Observables/src/model/color.ts new file mode 100644 index 0000000..e1a342b --- /dev/null +++ b/17 Observables/src/model/color.ts @@ -0,0 +1,5 @@ +export class Color { + red: number; + green: number; + blue: number; +} diff --git a/17 Observables/src/model/member.ts b/17 Observables/src/model/member.ts new file mode 100644 index 0000000..d5649c3 --- /dev/null +++ b/17 Observables/src/model/member.ts @@ -0,0 +1,11 @@ +export class MemberEntity { + id: number; + login: string; + avatar_url: string; + + constructor() { + this.id = -1; + this.login = ""; + this.avatar_url = ""; + } +} diff --git a/17 Observables/src/reducers/index.ts b/17 Observables/src/reducers/index.ts new file mode 100644 index 0000000..9ec1172 --- /dev/null +++ b/17 Observables/src/reducers/index.ts @@ -0,0 +1,8 @@ +import { combineReducers } from 'redux'; +import { userProfileReducer } from './userProfile'; +import { memberReducer } from './memberReducer'; + +export const reducers = combineReducers({ + userProfileReducer, + memberReducer, +}); diff --git a/17 Observables/src/reducers/memberReducer.ts b/17 Observables/src/reducers/memberReducer.ts new file mode 100644 index 0000000..f093fb5 --- /dev/null +++ b/17 Observables/src/reducers/memberReducer.ts @@ -0,0 +1,41 @@ +import { actionsEnums } from "../common/actionsEnums"; +import { MemberEntity } from "../model/member"; +import objectAssign = require("object-assign"); + +class memberState { + members: MemberEntity[]; + members_loading: boolean; + + public constructor() { + this.members = []; + this.members_loading = false; + } +} + +const handleMemberRequestCompletedAction = (state: memberState, action) => { + const newState = objectAssign({}, state, { members_loading: false, members: action.members }); + return newState; +} + +const handleMemberRequestStartedAction = (state: memberState, action) => { + const newState = objectAssign({}, state, { members_loading: true }); + return newState; +} + +const handleMemberRequestCancelledAction = (state: memberState, action) => { + const newState = objectAssign({}, state, { members_loading: false }); + return newState; +} + +export const memberReducer = (state: memberState = new memberState(), action) => { + switch (action.type) { + case actionsEnums.MEMBER_REQUEST_STARTED: + return handleMemberRequestStartedAction(state, action); + case actionsEnums.MEMBER_REQUEST_COMPLETED: + return handleMemberRequestCompletedAction(state, action); + case actionsEnums.MEMBER_REQUEST_CANCELLED: + return handleMemberRequestCancelledAction(state, action); + } + + return state; +}; diff --git a/17 Observables/src/reducers/userProfile.ts b/17 Observables/src/reducers/userProfile.ts new file mode 100644 index 0000000..858c825 --- /dev/null +++ b/17 Observables/src/reducers/userProfile.ts @@ -0,0 +1,35 @@ +import { actionsEnums } from "../common/actionsEnums"; +import { updateUserProfileName } from "../actions/updateUserProfileName"; +import { Color } from "../model/color"; +import objectAssign = require("object-assign"); + +class UserProfileState { + firstname: string; + favouriteColor: Color; + + public constructor() { + this.firstname = "Default name"; + this.favouriteColor = {red: 0, green: 0, blue: 180}; + } +} + +export const userProfileReducer = (state: UserProfileState = new UserProfileState(), action) => { + switch (action.type) { + case actionsEnums.UPDATE_USERPROFILE_NAME: + return handleUserProfileAction(state, action); + case actionsEnums.UPDATE_USERPROFILE_FAVOURITE_COLOR: + return handleFavouriteColorAction(state, action); + } + + return state; +}; + +const handleFavouriteColorAction = (state: UserProfileState, action) => { + const newState = objectAssign({}, state, {favouriteColor: action.newColor}); + return newState; +}; + +const handleUserProfileAction = (state: UserProfileState, action) => { + const newState = objectAssign({}, state, {firstname: action.newName}); + return newState; +}; diff --git a/17 Observables/src/restApi/memberApi.ts b/17 Observables/src/restApi/memberApi.ts new file mode 100644 index 0000000..b1aabdc --- /dev/null +++ b/17 Observables/src/restApi/memberApi.ts @@ -0,0 +1,13 @@ +import { ajax } from "rxjs/observable/dom/ajax"; + +// Sync mock data API, inspired from: +// https://gist.github.com/coryhouse/fd6232f95f9d601158e4 +class MemberAPI { + getAllMembers() { + return ( + ajax.getJSON("https://api.github.com/orgs/lemoncode/members") + ); + } +} + +export const memberAPI = new MemberAPI(); diff --git a/17 Observables/src/store.ts b/17 Observables/src/store.ts new file mode 100644 index 0000000..c3a8d45 --- /dev/null +++ b/17 Observables/src/store.ts @@ -0,0 +1,14 @@ +import { createStore, applyMiddleware, compose } from "redux"; +import { createEpicMiddleware } from "redux-observable"; +import { rootEpic } from "./epics"; +import { reducers } from "./reducers/"; + +const epicMiddleware = createEpicMiddleware(rootEpic); + +export const store = createStore( + reducers, + compose( + applyMiddleware(epicMiddleware), + window['devToolsExtension'] ? window['devToolsExtension']() : f => f + ), +); diff --git a/17 Observables/tsconfig.json b/17 Observables/tsconfig.json new file mode 100644 index 0000000..3cd7705 --- /dev/null +++ b/17 Observables/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "es5", + "module": "commonjs", + "declaration": false, + "noImplicitAny": false, + "jsx": "react", + "sourceMap": true, + "noLib": false, + "suppressImplicitAnyIndexErrors": true + }, + "compileOnSave": false, + "exclude": [ + "node_modules" + ] +} diff --git a/17 Observables/webpack.config.js b/17 Observables/webpack.config.js new file mode 100644 index 0000000..e16553d --- /dev/null +++ b/17 Observables/webpack.config.js @@ -0,0 +1,71 @@ +var path = require('path'); +var webpack = require('webpack'); +var HtmlWebpackPlugin = require('html-webpack-plugin'); + +var basePath = __dirname; + +module.exports = { + context: path.join(basePath, "src"), + resolve: { + extensions: ['', '.js', '.ts', '.tsx'] + }, + + entry: [ + './main.tsx', + '../node_modules/bootstrap/dist/css/bootstrap.css' + ], + output: { + path: path.join(basePath, 'dist'), + filename: 'bundle.js' + }, + + devtool: 'source-map', + + devServer: { + contentBase: './dist', //Content base + inline: true, //Enable watch and live reload + host: 'localhost', + port: 8080, + stats: 'errors-only' + }, + + module: { + loaders: [ + { + test: /\.(ts|tsx)$/, + exclude: /node_modules/, + loader: 'ts-loader' + }, + { + test: /\.css$/, + loader: 'style-loader!css-loader' + }, + // Loading glyphicons => https://github.com/gowravshekar/bootstrap-webpack + // Using here url-loader and file-loader + { + test: /\.(woff|woff2)(\?v=\d+\.\d+\.\d+)?$/, + loader: 'url?limit=10000&mimetype=application/font-woff' + }, + { + test: /\.ttf(\?v=\d+\.\d+\.\d+)?$/, + loader: 'url?limit=10000&mimetype=application/octet-stream' + }, + { + test: /\.eot(\?v=\d+\.\d+\.\d+)?$/, + loader: 'file' + }, + { + test: /\.svg(\?v=\d+\.\d+\.\d+)?$/, + loader: 'url?limit=10000&mimetype=image/svg+xml' + } + ] + }, + plugins: [ + // Generate index.html in /dist => https://github.com/ampedandwired/html-webpack-plugin + new HtmlWebpackPlugin({ + filename: 'index.html', // Name of file in ./dist/ + template: 'index.html', // Name of template in ./src + hash: true + }) + ] +}