Renamed chapter 9

This commit is contained in:
Federico Kereki
2018-08-24 19:43:22 -03:00
parent 56bf3b66b7
commit 4f00f6518b
64 changed files with 0 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
[Dolphin]
Timestamp=2018,7,29,12,22,28
Version=4
ViewMode=1
VisibleRoles=Details_text,Details_size,Details_modificationtime,Details_destination,Details_permissions,Details_owner,Details_group,CustomizedDetails
[Settings]
HiddenFilesShown=true
+25
View File
@@ -0,0 +1,25 @@
{
"parser": "babel-eslint",
"parserOptions": {
"ecmaVersion": 2017,
"sourceType": "module"
},
"env": {
"node": true,
"browser": true,
"es6": true,
"jest": true
},
"extends": [
"eslint:recommended",
"plugin:flowtype/recommended",
"plugin:react/recommended"
],
"plugins": ["babel", "flowtype", "react"],
"rules": {
"no-console": "off",
"no-var": "error",
"prefer-const": "error",
"flowtype/no-types-missing-file-annotation": 0
}
}
+17
View File
@@ -0,0 +1,17 @@
[ignore]
.*/node_modules/.*
[include]
[libs]
[lints]
all=warn
untyped-type-import=off
unsafe-getters-setters=off
[options]
include_warnings=true
module.file_ext=.scss
[strict]
+21
View File
@@ -0,0 +1,21 @@
# See https://help.github.com/ignore-files/ for more about ignoring files.
# dependencies
/node_modules
# testing
/coverage
# production
/build
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*
+5
View File
@@ -0,0 +1,5 @@
{
"tabWidth": 4,
"printWidth": 75
}
File diff suppressed because it is too large Load Diff
+12
View File
@@ -0,0 +1,12 @@
const rewireEslint = require("react-app-rewire-eslint");
function overrideEslintOptions(options) {
// do stuff with the eslint options...
return options;
}
/* global module */
module.exports = function override(config, env) {
config = rewireEslint(config, env, overrideEslintOptions);
return config;
};
+15691
View File
File diff suppressed because it is too large Load Diff
+52
View File
@@ -0,0 +1,52 @@
{
"name": "chapter09",
"version": "0.1.0",
"private": true,
"devDependencies": {
"babel-core": "^6.26.3",
"babel-eslint": "^8.2.5",
"babel-runtime": "^6.26.0",
"enzyme": "^3.4.1",
"enzyme-adapter-react-16": "^1.2.0",
"eslint": "^5.3.0",
"eslint-config-recommended": "^3.0.0",
"eslint-plugin-babel": "^5.1.0",
"eslint-plugin-flowtype": "^2.49.3",
"eslint-plugin-react": "^7.10.0",
"flow": "^0.2.3",
"flow-bin": "^0.75.0",
"flow-coverage-report": "^0.5.0",
"flow-typed": "^2.4.0",
"prettier": "^1.14.2",
"react-app-rewire-eslint": "^0.2.3",
"react-app-rewired": "^1.5.2",
"react-devtools": "^3.2.3",
"react-scripts": "1.1.4",
"react-test-renderer": "^16.4.2",
"redux-devtools-extension": "^2.13.5",
"redux-mock-store": "^1.5.3"
},
"dependencies": {
"axios": "^0.18.0",
"connected-react-router": "^4.3.0",
"debug": "^3.1.0",
"react": "^16.4.2",
"react-dom": "^16.4.2",
"react-loadable": "^5.4.0",
"react-redux": "^5.0.7",
"react-router-dom": "^4.3.1",
"redux": "^4.0.0",
"redux-logger": "^3.0.6",
"redux-thunk": "^2.3.0"
},
"scripts": {
"start": "react-app-rewired start",
"build": "react-app-rewired build",
"test": "react-app-rewired test --env=jsdom",
"coverage": "react-app-rewired test --env=jsdom --coverage --no-cache",
"eject": "react-app-rewired eject",
"flow": "flow",
"addTypes": "flow-typed install",
"devtools": "react-devtools"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

+44
View File
@@ -0,0 +1,44 @@
<!DOCTYPE html>
<html lang="en">
<head>
<!-- <script src="http://192.168.1.200:8097"></script> -->
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<meta name="theme-color" content="#000000">
<!--
manifest.json provides metadata used when your web app is added to the
homescreen on Android. See https://developers.google.com/web/fundamentals/engage-and-retain/web-app-manifest/
-->
<link rel="manifest" href="%PUBLIC_URL%/manifest.json">
<link rel="shortcut icon" href="%PUBLIC_URL%/favicon.ico">
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
Only files inside the `public` folder can be referenced from the HTML.
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>React App</title>
</head>
<body>
<noscript>
You need to enable JavaScript to run this app.
</noscript>
<div id="root"></div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
</body>
</html>
+15
View File
@@ -0,0 +1,15 @@
{
"short_name": "React App",
"name": "Create React App Sample",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
}
],
"start_url": "./index.html",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#ffffff"
}
+23
View File
@@ -0,0 +1,23 @@
/* @flow */
import React, { Component, Fragment } from "react";
import { Provider } from "react-redux";
import { store } from "./counterApp/store";
import { ConnectedCounter, ConnectedClicksDisplay } from "./counterApp";
class App extends Component<{}> {
render() {
return (
<Provider store={store}>
<Fragment>
<ConnectedCounter />
<hr />
<ConnectedClicksDisplay />
</Fragment>
</Provider>
);
}
}
export default App;
+26
View File
@@ -0,0 +1,26 @@
/* @flow */
import React, { Component, Fragment } from "react";
import { Provider } from "react-redux";
import {
ConnectedCountrySelect,
ConnectedRegionsTable
} from "./regionsApp";
import { store } from "./regionsApp/store";
class App extends Component<{}> {
render() {
return (
<Provider store={store}>
<Fragment>
<ConnectedCountrySelect />
<ConnectedRegionsTable />
</Fragment>
</Provider>
);
}
}
export default App;
+91
View File
@@ -0,0 +1,91 @@
/* noflow */
/* eslint-disable */
import React, { Component } from "react";
import { Provider } from "react-redux";
import { BrowserRouter, Switch, Route, Link } from "react-router-dom";
import { ConnectedRouter } from "connected-react-router";
import {
ConnectedLogin,
ConnectedProtectedRoutes,
AuthRoute
} from "./routingApp";
import { history, store } from "./routingApp/store";
const Home = () => <h1>Home Sweet Home</h1>;
const Help = () => <h1>Help! SOS!</h1>;
const Alpha = () => <h1>Alpha</h1>;
const Bravo = () => <h1>Bravo</h1>;
const Charlie = () => <h1>Charlie</h1>;
const Zulu = () => <h1>Zulu</h1>;
const Error404 = () => <h1>404 Error!</h1>;
class App extends Component<{}> {
render() {
return (
<Provider store={store}>
<BrowserRouter>
<ConnectedRouter history={history}>
<div>
<header>
<nav>
<Link to="/">Home</Link>&nbsp;
<Link to="/login">Log in</Link>&nbsp;
<Link to="/about/routing">
About Routing
</Link>&nbsp;
<Link to="/alpha">Alpha...</Link>&nbsp;
<Link to="/bravo">Bravo...</Link>&nbsp;
<Link to="/charlie">
Charlie...
</Link>&nbsp;
<Link to="/wrong">
...Wrong...
</Link>&nbsp;
<Link to="/zulu">Zulu</Link>&nbsp;
<Link to="/help">Help</Link>&nbsp;
</nav>
</header>
<Switch>
<Route exact path="/" component={Home} />
<Route path="/help" component={Help} />
<Route
path="/about/:something"
render={props => (
<div>
<h1>About...</h1>
{props.match.params.something}
</div>
)}
/>
<Route
path="/login"
component={ConnectedLogin}
/>
<AuthRoute
path="/alpha"
component={Alpha}
/>
<AuthRoute
path="/bravo"
component={Bravo}
/>
<AuthRoute
path="/charlie"
component={Charlie}
/>
<AuthRoute path="/zulu" component={Zulu} />
<AuthRoute component={Error404} />
</Switch>
</div>
</ConnectedRouter>
</BrowserRouter>
</Provider>
);
}
}
export default App;
@@ -0,0 +1,8 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`clicksDisplay renders correctly 1`] = `
<div>
Clicks so far:
22
</div>
`;
@@ -0,0 +1,24 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`clicksDisplay renders correctly 1`] = `
<div>
Value:
9
<br />
<button
onClick={[Function]}
>
Add 1
</button>
<button
onClick={[Function]}
>
Subtract 2
</button>
<button
onClick={[Function]}
>
Reset
</button>
</div>
`;
@@ -0,0 +1,16 @@
/* @flow */
import React from "react";
import { PropTypes } from "prop-types";
export class ClicksDisplay extends React.PureComponent<{
clicks: number
}> {
static propTypes = {
clicks: PropTypes.number.isRequired
};
render() {
return <div>Clicks so far: {this.props.clicks}</div>;
}
}
@@ -0,0 +1,11 @@
/* @flow */
import { connect } from "react-redux";
import { ClicksDisplay } from "./clicksDisplay.component";
const getProps = state => ({
clicks: state.clicks
});
export const ConnectedClicksDisplay = connect(getProps)(ClicksDisplay);
@@ -0,0 +1,13 @@
import React from "react";
import TestRenderer from "react-test-renderer";
import { ClicksDisplay } from "./clicksDisplay.component";
describe("clicksDisplay", () => {
it("renders correctly", () => {
const tree = TestRenderer.create(
<ClicksDisplay clicks={22} />
).toJSON();
expect(tree).toMatchSnapshot();
});
});
@@ -0,0 +1,29 @@
/* @flow */
export const COUNTER_INCREMENT = "counter:increment";
export const COUNTER_RESET = "counter:reset";
export type CounterAction = {
type: string,
value?: number
};
export const reset = () =>
({
type: COUNTER_RESET
}: CounterAction);
export const increment = (inc: number) =>
({
type: COUNTER_INCREMENT,
value: inc
}: CounterAction);
export const decrement = (dec: number) =>
({
type: COUNTER_INCREMENT,
value: -dec
}: CounterAction);
// returning increment(-dec) would have worked as well
@@ -0,0 +1,37 @@
/* @flow */
import React from "react";
import { PropTypes } from "prop-types";
import {
increment,
decrement,
reset,
CounterAction
} from "./counter.actions";
export class Counter extends React.PureComponent<{
count: number,
dispatch: CounterAction => any
}> {
static propTypes = {
count: PropTypes.number.isRequired,
dispatch: PropTypes.func.isRequired
};
onAdd1 = () => this.props.dispatch(increment(1));
onSub2 = () => this.props.dispatch(decrement(2));
onReset = () => this.props.dispatch(reset());
render() {
return (
<div>
Value: {this.props.count}
<br />
<button onClick={this.onAdd1}>Add 1</button>
<button onClick={this.onSub2}>Subtract 2</button>
<button onClick={this.onReset}>Reset</button>
</div>
);
}
}
@@ -0,0 +1,9 @@
/* @flow */
import { connect } from "react-redux";
import { Counter } from "./counter.component";
const getProps = state => ({ count: state.count });
export const ConnectedCounter = connect(getProps)(Counter);
@@ -0,0 +1,28 @@
/* @flow */
import { COUNTER_INCREMENT, COUNTER_RESET } from "./counter.actions";
import type { CounterAction } from "./counter.actions.js";
export const reducer = (
state = {
// initial state
count: 0,
clicks: 0
},
action: CounterAction
) => {
switch (action.type) {
case COUNTER_INCREMENT:
return {
count: state.count + action.value,
clicks: state.clicks + 1
};
case COUNTER_RESET:
return { count: 0, clicks: state.clicks + 1 };
default:
return state;
}
};
@@ -0,0 +1,13 @@
import React from "react";
import TestRenderer from "react-test-renderer";
import { Counter } from "./counter.component";
describe("clicksDisplay", () => {
it("renders correctly", () => {
const tree = TestRenderer.create(
<Counter count={9} dispatch={() => null} />
).toJSON();
expect(tree).toMatchSnapshot();
});
});
+14
View File
@@ -0,0 +1,14 @@
/* @flow */
import { Counter } from "./counter.component.js";
import { ConnectedCounter } from "./counter.connected.js";
import { ClicksDisplay } from "./clicksDisplay.component.js";
import { ConnectedClicksDisplay } from "./clicksDisplay.connected.js";
export {
Counter,
ConnectedCounter,
ClicksDisplay,
ConnectedClicksDisplay
};
+27
View File
@@ -0,0 +1,27 @@
/* @flow */
import { createStore, applyMiddleware } from "redux";
import { createLogger } from "redux-logger";
import { composeWithDevTools } from "redux-devtools-extension";
import { reducer } from "./counter.reducer.js";
const logger = createLogger({
diff: true,
duration: true
});
// The following uses only redux-logger
/*
export const store =
process.env.NODE_ENV === "development"
? createStore(reducer, applyMiddleware(logger))
: createStore(reducer);
*/
// The following uses redux-devtools-extension in addition to redux-logger
export const store = createStore(
reducer,
composeWithDevTools(applyMiddleware(logger))
);
+5
View File
@@ -0,0 +1,5 @@
.bordered {
border: 1px solid blue;
padding: 2px;
margin: 4px;
}
+26
View File
@@ -0,0 +1,26 @@
import React from "react";
import ReactDOM from "react-dom";
import App from "./App.routing.auth";
import registerServiceWorker from "./registerServiceWorker";
import { log } from "./logging";
log.error("myapp:SERVICE:LOGIN", `Attempt`, { user: "FK", pass: "who?" });
log.error("myapp:FORM:INITIAL", "Doing render");
log.info(
"myapp:SERVICE:ERROR_STORE",
"Reporting problem",
"Something wrong",
404
);
log.warn("myapp:SERVICE:LOGIN");
log.debug("myapp:SERVICE:INFO", "This won't be logged... low level");
log.info("myapp:SERVICE:GETDATE", "Success", {
day: 22,
month: 9,
year: 60
});
log.verbose("myapp:SERVICE:LOGIN", "Successful login");
ReactDOM.render(<App />, document.getElementById("root"));
registerServiceWorker();
@@ -0,0 +1,8 @@
import React from "react";
import ReactDOM from "react-dom";
import App from "./App.routing.auth";
import registerServiceWorker from "./registerServiceWorker";
ReactDOM.render(<App />, document.getElementById("root"));
registerServiceWorker();
+55
View File
@@ -0,0 +1,55 @@
/* @flow */
import debug from "debug";
const WHAT_TO_LOG = "myapp:SERVICE:*"; // change this to suit your needs
const MIN_LEVEL_TO_LOG = "info"; // error, warn, info, verbose, or debug
const log = {
error() {},
warn() {},
info() {},
verbose() {},
debug() {}
};
const logMessage = (
color: string,
topic: string,
message: any = "--",
...rest: any
) => {
const logger = debug(topic);
logger.color = color;
logger(message, ...rest);
};
if (process.env.NODE_ENV === "development") {
localStorage.setItem("debug", WHAT_TO_LOG);
/* eslint-disable no-fallthrough */
switch (MIN_LEVEL_TO_LOG) {
case "debug":
log.debug = (topic: string, ...args: any) =>
logMessage("gray", topic, ...args);
case "verbose":
log.verbose = (topic: string, ...args: any) =>
logMessage("green", topic, ...args);
case "info":
log.info = (topic: string, ...args: any) =>
logMessage("blue", topic, ...args);
case "warn":
log.warn = (topic: string, ...args: any) =>
logMessage("brown", topic, ...args);
case "error":
default:
log.error = (topic: string, ...args: any) =>
logMessage("red", topic, ...args);
}
}
export { log };
@@ -0,0 +1,62 @@
/* @flow */
import debug from "debug";
const WHAT_TO_LOG = "myapp:SERVICE:*"; // change this to suit your needs
let log;
if (process.env.NODE_ENV === "development") {
localStorage.setItem("debug", WHAT_TO_LOG);
const ERROR_COLOR = "red";
const WARN_COLOR = "brown";
const INFO_COLOR = "blue";
const VERBOSE_COLOR = "green";
const DEBUG_COLOR = "gray";
// CHANGE TO: COLOR, TOPIC, MESSAGE, ...EXTRA)
log = {
logMessage(
color: string,
topic: string,
message: any = "--",
...rest: any
) {
const logger = debug(topic);
logger.color = color;
logger(message, ...rest);
},
error(topic: string, ...args: any) {
this.logMessage(ERROR_COLOR, topic, ...args);
},
warn(topic: string, ...args: any) {
this.logMessage(WARN_COLOR, topic, ...args);
},
info(topic: string, ...args: any) {
this.logMessage(INFO_COLOR, topic, ...args);
},
verbose(topic: string, ...args: any) {
this.logMessage(VERBOSE_COLOR, topic, ...args);
},
debug(topic: string, ...args: any) {
this.logMessage(DEBUG_COLOR, topic, ...args);
}
};
} else {
log = {
error() {},
warn() {},
info() {},
verbose() {},
debug() {}
};
}
export { log };
@@ -0,0 +1,21 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`App for Regions and Countries renders correctly 1`] = `
<div>
<div>
Select:
<CountrySelect
getCountries={[Function]}
list={Array []}
loading={true}
onSelect={[Function]}
/>
</div>
<div>
Display:
<RegionsTable
list={Array []}
/>
</div>
</div>
`;
@@ -0,0 +1,42 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`CountrySelect renders correctly a countries dropdown 1`] = `
<div
className="bordered"
>
Country: 
<select
name="selectCountry"
onChange={[Function]}
>
<option
value=""
>
Select a country:
</option>
<option
value="AR"
>
Argentina
</option>
<option
value="BR"
>
Brazil
</option>
<option
value="UY"
>
Uruguay
</option>
</select>
</div>
`;
exports[`CountrySelect renders correctly when loading, with no countries 1`] = `
<div
className="bordered"
>
Loading countries...
</div>
`;
@@ -0,0 +1,25 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`RegionsTable renders correctly a list 1`] = `
<div
className="bordered"
>
<div>
Cerro Largo
</div>
<div>
Maldonado
</div>
<div>
Montevideo
</div>
</div>
`;
exports[`RegionsTable renders correctly an empty list 1`] = `
<div
className="bordered"
>
No regions.
</div>
`;
@@ -0,0 +1,33 @@
import React from "react";
import ShallowRenderer from "react-test-renderer/shallow";
import { CountrySelect } from "./countrySelect.component";
import { RegionsTable } from "./regionsTable.component";
class CountryAndRegions extends React.Component {
render() {
return (
<div>
<div>
Select:
<CountrySelect
loading={true}
onSelect={() => null}
getCountries={() => null}
list={[]}
/>
</div>
<div>
Display: <RegionsTable list={[]} />
</div>
</div>
);
}
}
describe("App for Regions and Countries", () => {
it("renders correctly", () => {
const tree = new ShallowRenderer().render(<CountryAndRegions />);
expect(tree).toMatchSnapshot();
});
});
@@ -0,0 +1,53 @@
/* @flow */
import React from "react";
import PropTypes from "prop-types";
import "../general.css";
export class CountrySelect extends React.PureComponent<{
dispatch: ({}) => any
}> {
static propTypes = {
loading: PropTypes.bool.isRequired,
list: PropTypes.arrayOf(PropTypes.object).isRequired,
onSelect: PropTypes.func.isRequired,
getCountries: PropTypes.func.isRequired
};
componentDidMount() {
if (this.props.list.length === 0) {
this.props.getCountries();
}
}
onSelect = (e: { target: HTMLOptionElement }) =>
this.props.onSelect(e.target.value);
render() {
if (this.props.loading) {
return <div className="bordered">Loading countries...</div>;
} else {
const sortedCountries = [...this.props.list].sort(
(a, b) => (a.countryName < b.countryName ? -1 : 1)
);
return (
<div className="bordered">
Country:&nbsp;
<select onChange={this.onSelect} name="selectCountry">
<option value="">Select a country:</option>
{sortedCountries.map(x => (
<option
key={x.countryCode}
value={x.countryCode}
>
{x.countryName}
</option>
))}
</select>
</div>
);
}
}
}
@@ -0,0 +1,21 @@
/* @flow */
import { connect } from "react-redux";
import { CountrySelect } from "./countrySelect.component";
import { getCountries, getRegions } from "./world.actions";
const getProps = state => ({
list: state.countries,
loading: state.loadingCountries
});
const getDispatch = dispatch => ({
getCountries: () => dispatch(getCountries()),
onSelect: c => dispatch(getRegions(c))
});
export const ConnectedCountrySelect = connect(
getProps,
getDispatch
)(CountrySelect);
@@ -0,0 +1,45 @@
/* @flow */
import React from "react";
import TestRenderer from "react-test-renderer";
import { CountrySelect } from "./countrySelect.component";
describe("CountrySelect", () => {
it("renders correctly when loading, with no countries", () => {
const tree = TestRenderer.create(
<CountrySelect
loading={true}
onSelect={() => null}
getCountries={() => null}
list={[]}
/>
).toJSON();
expect(tree).toMatchSnapshot();
});
it("renders correctly a countries dropdown", () => {
const tree = TestRenderer.create(
<CountrySelect
loading={false}
onSelect={() => null}
getCountries={() => null}
list={[
{
countryCode: "UY",
countryName: "Uruguay"
},
{
countryCode: "AR",
countryName: "Argentina"
},
{
countryCode: "BR",
countryName: "Brazil"
}
]}
/>
).toJSON();
expect(tree).toMatchSnapshot();
});
});
@@ -0,0 +1,88 @@
/* @flow */
import React from "react";
import Enzyme from "enzyme";
import Adapter from "enzyme-adapter-react-16";
import { CountrySelect } from "./countrySelect.component";
Enzyme.configure({ adapter: new Adapter() });
const threeCountries = [
{
countryCode: "UY",
countryName: "Uruguay"
},
{
countryCode: "AR",
countryName: "Argentina"
},
{
countryCode: "BR",
countryName: "Brazil"
}
];
describe("CountrySelect", () => {
it("renders correctly when loading, with no countries", () => {
const mockGetCountries = jest.fn();
const mockOnSelect = jest.fn();
const wrapper = Enzyme.mount(
<CountrySelect
loading={true}
onSelect={mockOnSelect}
getCountries={mockGetCountries}
list={[]}
/>
);
expect(wrapper.text()).toContain("Loading countries");
expect(mockGetCountries).toHaveBeenCalledTimes(1);
expect(mockOnSelect).not.toHaveBeenCalled();
});
it("renders correctly a countries dropdown", () => {
const mockGetCountries = jest.fn();
const mockOnSelect = jest.fn();
const wrapper = Enzyme.mount(
<CountrySelect
loading={false}
onSelect={mockOnSelect}
getCountries={mockGetCountries}
list={threeCountries}
/>
);
expect(wrapper.text()).toContain("Uruguay");
expect(wrapper.text()).toContain("Argentina");
expect(wrapper.text()).toContain("Brazil");
expect(mockGetCountries).not.toHaveBeenCalled();
expect(mockOnSelect).not.toHaveBeenCalled();
});
it("correctly calls onSelect", () => {
const mockGetCountries = jest.fn();
const mockOnSelect = jest.fn();
const wrapper = Enzyme.mount(
<CountrySelect
loading={false}
onSelect={mockOnSelect}
getCountries={mockGetCountries}
list={threeCountries}
/>
);
wrapper
.find("[name='selectCountry']")
.at(0)
.simulate("change", { target: { value: "UY" } });
expect(mockGetCountries).not.toHaveBeenCalled();
expect(mockOnSelect).toHaveBeenCalledTimes(1);
expect(mockOnSelect).toHaveBeenCalledWith("UY");
});
});
+6
View File
@@ -0,0 +1,6 @@
/* @flow */
import { ConnectedCountrySelect } from "./countrySelect.connected.js";
import { ConnectedRegionsTable } from "./regionsTable.connected.js";
export { ConnectedCountrySelect, ConnectedRegionsTable };
@@ -0,0 +1,41 @@
/* @flow */
import React from "react";
import PropTypes from "prop-types";
import "../general.css";
export class RegionsTable extends React.PureComponent<{
list: Array<{
regionCode: string,
regionName: string
}>
}> {
static propTypes = {
list: PropTypes.arrayOf(PropTypes.object)
};
static defaultProps = {
list: []
};
render() {
if (this.props.list.length === 0) {
return <div className="bordered">No regions.</div>;
} else {
const ordered = [...this.props.list].sort(
(a, b) => (a.regionName < b.regionName ? -1 : 1)
);
return (
<div className="bordered">
{ordered.map(x => (
<div key={x.countryCode + "-" + x.regionCode}>
{x.regionName}
</div>
))}
</div>
);
}
}
}
@@ -0,0 +1,12 @@
/* @flow */
import { connect } from "react-redux";
import { RegionsTable } from "./regionsTable.component";
export const getProps = state => ({
list: state.regions,
loading: state.loadingRegions
});
export const ConnectedRegionsTable = connect(getProps)(RegionsTable);
@@ -0,0 +1,28 @@
/* @flow */
import Enzyme from "enzyme/build";
import Adapter from "enzyme-adapter-react-16/build";
import { getProps } from "./regionsTable.connected.js";
Enzyme.configure({ adapter: new Adapter() });
describe("getProps for RegionsTable", () => {
it("should extract regions and loading", () => {
const initialState = {
loadingCountries: false,
currentCountry: "whatever",
countries: [{ other: 1 }, { other: 2 }, { other: 3 }],
loadingRegions: false,
regions: [{ something: 1 }, { something: 2 }]
};
const initialJSON = JSON.stringify(initialState);
expect(getProps(initialState)).toEqual({
list: [{ something: 1 }, { something: 2 }],
loading: false
});
expect(JSON.stringify(initialState)).toBe(initialJSON);
});
});
@@ -0,0 +1,40 @@
/* @flow */
import React from "react";
import TestRenderer from "react-test-renderer";
import { RegionsTable } from "./regionsTable.component";
describe("RegionsTable", () => {
it("renders correctly an empty list", () => {
const tree = TestRenderer.create(
<RegionsTable list={[]} />
).toJSON();
expect(tree).toMatchSnapshot();
});
it("renders correctly a list", () => {
const tree = TestRenderer.create(
<RegionsTable
list={[
{
countryCode: "UY",
regionCode: "10",
regionName: "Montevideo"
},
{
countryCode: "UY",
regionCode: "9",
regionName: "Maldonado"
},
{
countryCode: "UY",
regionCode: "5",
regionName: "Cerro Largo"
}
]}
/>
).toJSON();
expect(tree).toMatchSnapshot();
});
});
@@ -0,0 +1,43 @@
/* @flow */
import React from "react";
import Enzyme from "enzyme";
import Adapter from "enzyme-adapter-react-16";
import { RegionsTable } from "./regionsTable.component";
Enzyme.configure({ adapter: new Adapter() });
describe("RegionsTable", () => {
it("renders correctly an empty list", () => {
const wrapper = Enzyme.render(<RegionsTable list={[]} />);
expect(wrapper.text()).toContain("No regions.");
});
it("renders correctly a list", () => {
const wrapper = Enzyme.render(
<RegionsTable
list={[
{
countryCode: "UY",
regionCode: "10",
regionName: "Montevideo"
},
{
countryCode: "UY",
regionCode: "9",
regionName: "Maldonado"
},
{
countryCode: "UY",
regionCode: "5",
regionName: "Cerro Largo"
}
]}
/>
);
expect(wrapper.text()).toContain("Montevideo");
expect(wrapper.text()).toContain("Maldonado");
expect(wrapper.text()).toContain("Cerro Largo");
});
});
@@ -0,0 +1,9 @@
/* @flow */
import axios from "axios";
export const getCountriesAPI = () =>
axios.get(`http://fk-server:8080/countries`);
export const getRegionsAPI = country =>
axios.get(`http://fk-server:8080/regions/${country}`);
+22
View File
@@ -0,0 +1,22 @@
/* @flow */
import { createStore, applyMiddleware } from "redux";
import thunk from "redux-thunk";
import { createLogger } from "redux-logger";
import { composeWithDevTools } from "redux-devtools-extension";
import { reducer } from "./world.reducer.js";
const logger = createLogger({ duration: true });
// The following uses redux-logger, only.
/*
export const store = createStore(reducer, applyMiddleware(thunk, logger));
*/
// The following uses redux-devtools-extension in addition to redux-logger
export const store = createStore(
reducer,
composeWithDevTools(applyMiddleware(thunk, logger))
);
@@ -0,0 +1,122 @@
/* @flow */
import { getCountriesAPI, getRegionsAPI } from "./serviceApi";
// Countries actions
export const COUNTRIES_REQUEST = "countries:request";
export const COUNTRIES_SUCCESS = "countries:success";
export const COUNTRIES_FAILURE = "countries:failure";
export type CountriesAction = {
type: string,
country?: string,
listOfCountries?: [object]
};
export const countriesRequest = () =>
({
type: COUNTRIES_REQUEST
}: CountriesActions);
export const countriesSuccess = (listOfCountries: []) =>
({
type: COUNTRIES_SUCCESS,
listOfCountries
}: CountriesActions);
export const countriesFailure = () =>
({
type: COUNTRIES_FAILURE
}: CountriesActions);
// Regions actions
export const REGIONS_REQUEST = "regions:request";
export const REGIONS_SUCCESS = "regions:success";
export const REGIONS_FAILURE = "regions:failure";
export type RegionsAction = {
type: string,
listOfRegions?: [object]
};
export const regionsRequest = (country: string) =>
({
type: REGIONS_REQUEST,
country
}: RegionsActions);
export const regionsSuccess = (listOfRegions: [{}]) =>
({
type: REGIONS_SUCCESS,
listOfRegions
}: RegionsActions);
export const regionsFailure = () =>
({
type: REGIONS_FAILURE
}: RegionsActions);
// Complex Actions:
export const getCountries = () => async dispatch => {
try {
dispatch(countriesRequest());
// the next line delays execution for 5 seconds:
// await new Promise(resolve => setTimeout(resolve, 5000));
const result = await getCountriesAPI();
dispatch(countriesSuccess(result.data));
} catch (e) {
dispatch(countriesFailure());
}
};
export const getCountries2 = () => async (dispatch, getState) => {
if (getState().countries.length) {
// no need to do anything!
} else {
try {
dispatch(countriesRequest());
const result = await getCountriesAPI();
dispatch(countriesSuccess(result.data));
} catch (e) {
dispatch(countriesFailure());
}
}
};
export const getRegions = (country: string) => async dispatch => {
if (country) {
try {
dispatch(regionsRequest(country));
const result = await getRegionsAPI(country);
dispatch(regionsSuccess(result.data));
} catch (e) {
dispatch(regionsFailure());
}
} else {
dispatch(regionsFailure());
}
};
export const getRegions2 = (country: string) => async (
dispatch,
getState
) => {
if (country === getState().currentCountry) {
console.log("Hey! You are getting the same country as before!");
}
if (country) {
try {
dispatch(regionsRequest(country));
const result = await getRegionsAPI(country);
dispatch(regionsSuccess(result.data));
} catch (e) {
dispatch(regionsFailure());
}
} else {
dispatch(regionsFailure());
}
};
@@ -0,0 +1,81 @@
/* @flow */
import configureMockStore from "redux-mock-store";
import thunk from "redux-thunk";
import {
getCountries,
getCountries2,
countriesSuccess,
COUNTRIES_REQUEST,
COUNTRIES_SUCCESS,
COUNTRIES_FAILURE
} from "./world.actions.js";
import { getCountriesAPI } from "./serviceApi";
let mockPromise;
jest.mock("./serviceApi", () => {
return {
getCountriesAPI: jest.fn().mockImplementation(() => mockPromise)
};
});
describe("getCountries", () => {
it("on API success", async () => {
const fakeCountries = {
data: [{ code: "UY" }, { code: "AR" }, { code: "BR" }]
};
mockPromise = Promise.resolve(fakeCountries);
const store = configureMockStore([thunk])({});
await store.dispatch(getCountries());
const dispatchedActions = store.getActions();
expect(getCountriesAPI).toHaveBeenCalledWith();
expect(dispatchedActions.length).toBe(2);
expect(dispatchedActions[0].type).toBe(COUNTRIES_REQUEST);
expect(dispatchedActions[1].type).toBe(COUNTRIES_SUCCESS);
expect(dispatchedActions[1].listOfCountries).toEqual(
fakeCountries.data
);
/*
We could have written... but then we'd need tests for countriesSuccess()
*/
expect(dispatchedActions[1]).toEqual(
countriesSuccess(fakeCountries.data)
);
});
it("on API failure", async () => {
mockPromise = Promise.reject(new Error("failure!"));
const store = configureMockStore([thunk])({});
await store.dispatch(getCountries());
const dispatchedActions = store.getActions();
expect(getCountriesAPI).toHaveBeenCalledWith();
expect(dispatchedActions.length).toBe(2);
expect(dispatchedActions[0].type).toBe(COUNTRIES_REQUEST);
expect(dispatchedActions[1].type).toBe(COUNTRIES_FAILURE);
});
});
describe("optimized getCountries", () => {
it("doesn't do unneeded calls", async () => {
const store = configureMockStore([thunk])({
countries: [{ land: 1 }, { land: 2 }]
});
jest.resetAllMocks();
await store.dispatch(getCountries2());
expect(getCountriesAPI).not.toHaveBeenCalled();
expect(store.getActions().length).toBe(0);
});
});
@@ -0,0 +1,74 @@
/* @flow */
import {
COUNTRIES_REQUEST,
COUNTRIES_SUCCESS,
COUNTRIES_FAILURE,
REGIONS_REQUEST,
REGIONS_SUCCESS,
REGIONS_FAILURE
} from "./world.actions";
import type { CountriesAction, RegionsAction } from "./world.actions";
// import type { CounterAction } from "./world.actions.js";
export const reducer = (
state: object = {
// initial state
loadingCountries: false,
currentCountry: "",
countries: [],
loadingRegions: false,
regions: []
},
action: CountriesAction | RegionsAction
) => {
switch (action.type) {
case COUNTRIES_REQUEST:
return {
...state,
loadingCountries: true,
countries: []
};
case COUNTRIES_SUCCESS:
return {
...state,
loadingCountries: false,
countries: action.listOfCountries
};
case COUNTRIES_FAILURE:
return {
...state,
loadingCountries: false,
countries: []
};
case REGIONS_REQUEST:
return {
...state,
loadingRegions: true,
currentCountry: action.country,
regions: []
};
case REGIONS_SUCCESS:
return {
...state,
loadingRegions: false,
regions: action.listOfRegions
};
case REGIONS_FAILURE:
return {
...state,
loadingRegions: false,
regions: []
};
default:
return state;
}
};
@@ -0,0 +1,76 @@
/* @flow */
import { reducer } from "./world.reducer.js";
import { countriesRequest, regionsSuccess } from "./world.actions.js";
describe("The countries and regions reducer", () => {
it("should process countryRequest actions", () => {
const initialState = {
loadingCountries: false,
currentCountry: "whatever",
countries: [{}, {}, {}],
loadingRegions: false,
regions: [{}, {}]
};
const initialJSON = JSON.stringify(initialState);
expect(reducer(initialState, countriesRequest())).toEqual({
loadingCountries: true,
currentCountry: "whatever",
countries: [],
loadingRegions: false,
regions: [{}, {}]
});
expect(JSON.stringify(initialState)).toBe(initialJSON);
});
it("should process regionsSuccess actions", () => {
const initialState = {
loadingCountries: false,
currentCountry: "whatever",
countries: [{}, {}, {}],
loadingRegions: true,
regions: []
};
const initialJSON = JSON.stringify(initialState);
expect(
reducer(
initialState,
regionsSuccess([
{ something: 1 },
{ something: 2 },
{ something: 3 }
])
)
).toEqual({
loadingCountries: false,
currentCountry: "whatever",
countries: [{}, {}, {}],
loadingRegions: false,
regions: [{ something: 1 }, { something: 2 }, { something: 3 }]
});
expect(JSON.stringify(initialState)).toBe(initialJSON);
});
it("should return the initial state for unknown actions", () => {
const initialState = {
loadingCountries: false,
currentCountry: "whatever",
countries: [{}, {}, {}],
loadingRegions: true,
regions: []
};
const initialJSON = JSON.stringify(initialState);
expect(
JSON.stringify(reducer(initialState, { actionType: "other" }))
).toBe(initialJSON);
expect(JSON.stringify(initialState)).toBe(initialJSON);
});
});
+117
View File
@@ -0,0 +1,117 @@
// 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);
// Add some additional logging to localhost, pointing developers to the
// service worker/PWA documentation.
navigator.serviceWorker.ready.then(() => {
console.log(
'This web app is being served cache-first by a service ' +
'worker. To learn more, visit https://goo.gl/SC7cgQ'
);
});
} 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();
});
}
}
@@ -0,0 +1,33 @@
/* @flow */
import React from "react";
import { Route, Redirect } from "react-router-dom";
import PropTypes from "prop-types";
export class Auth extends React.Component<{
loginRoute: string,
token: string,
location: object
}> {
static propTypes = {
loginRoute: PropTypes.string.isRequired,
token: PropTypes.string.isRequired,
location: PropTypes.object
};
render() {
const myProps = { ...this.props };
if (!myProps.token) {
delete myProps.component;
myProps.render = () => (
<Redirect
to={{
pathname: this.props.loginRoute,
state: { from: this.props.location }
}}
/>
);
}
return <Route {...myProps} />;
}
}
@@ -0,0 +1,9 @@
/* @flow */
import { connect } from "react-redux";
import { Auth } from "./authRoute.component";
export const AuthRoute = connect(state => ({
token: state.token,
loginRoute: "/login"
}))(Auth);
+7
View File
@@ -0,0 +1,7 @@
/* @flow */
import { ConnectedLogin } from "./login.connected.js";
import { ConnectedProtectedRoutes } from "./protectedRoutes.connected.js";
import { AuthRoute } from "./authRoute.connected.js";
export { ConnectedLogin, ConnectedProtectedRoutes, AuthRoute };
@@ -0,0 +1,37 @@
/* @flow */
import { loginService } from "./serviceApi";
export const LOGIN_REQUEST = "login:request";
export const LOGIN_SUCCESS = "login:success";
export const LOGIN_FAILURE = "login:failure";
export const loginRequest = () => ({
type: LOGIN_REQUEST
});
export const loginSuccess = (token: string) => ({
type: LOGIN_SUCCESS,
token
});
export const loginFailure = () => ({
type: LOGIN_FAILURE
});
// Complex actions:
export const attemptLogin = (
user: string,
password: string
) => async dispatch => {
try {
dispatch(loginRequest());
// the next line delays execution for 5 seconds:
// await new Promise(resolve => setTimeout(resolve, 5000));
const result = await loginService(user, password);
dispatch(loginSuccess(result.data));
} catch (e) {
dispatch(loginFailure());
}
};
@@ -0,0 +1,68 @@
/* @flow */
import React from "react";
import PropTypes from "prop-types";
import { Redirect } from "react-router-dom";
export class Login extends React.PureComponent<{
onLogin: func,
logging: boolean,
token: string,
location: object
}> {
static propTypes = {
onLogin: PropTypes.func.isRequired,
logging: PropTypes.bool.isRequired,
token: PropTypes.string.isRequired,
location: PropTypes.object
};
state = {
userName: "",
password: ""
};
onUserNameBlur = e => this.setState({ userName: e.target.value });
onPasswordBlur = e => this.setState({ password: e.target.value });
onLoginClick = () =>
this.props.onLogin(this.state.userName, this.state.password);
render() {
if (
this.state.userName &&
this.state.password &&
this.props.token
) {
return (
<Redirect to={this.props.location.state.from.pathname} />
);
} else {
return (
<div>
<h1>Login Form</h1>
<div>
User:<input
type="text"
onBlur={this.onUserNameBlur}
/>
</div>
<div>
Password:
<input
type="password"
onBlur={this.onPasswordBlur}
/>
</div>
<button
onClick={this.onLoginClick}
disabled={this.props.logging}
>
Login
</button>
</div>
);
}
}
}
@@ -0,0 +1,17 @@
/* @flow */
import { connect } from "react-redux";
import { Login } from "./login.component";
import { attemptLogin } from "./login.actions";
export const ConnectedLogin = connect(
state => ({
logging: state.logging,
token: state.token
}),
dispatch => ({
onLogin: (user: string, password: string) =>
dispatch(attemptLogin(user, password))
})
)(Login);
@@ -0,0 +1,41 @@
/* @flow */
import {
LOGIN_REQUEST,
LOGIN_SUCCESS,
LOGIN_FAILURE
} from "./login.actions";
export const reducer = (
state: object = {
// initial state
logging: false,
token: ""
},
action
) => {
switch (action.type) {
case LOGIN_REQUEST:
return {
...state,
logging: true,
token: ""
};
case LOGIN_SUCCESS:
return {
...state,
logging: false,
token: action.token
};
case LOGIN_FAILURE:
return {
...state,
logging: false
};
default:
return state;
}
};
@@ -0,0 +1,30 @@
/* @flow */
import React from "react";
import { Redirect, Switch } from "react-router-dom";
import PropTypes from "prop-types";
export class ProtectedRoutes extends React.Component<{
token: string,
children: any,
location: object
}> {
static propTypes = {
token: PropTypes.string.isRequired,
children: PropTypes.any,
location: PropTypes.object.isRequired
};
render() {
return this.props.token ? (
<Switch>{this.props.children}</Switch>
) : (
<Redirect
to={{
pathname: "/login",
state: { from: this.props.location }
}}
/>
);
}
}
@@ -0,0 +1,9 @@
/* @flow */
import { connect } from "react-redux";
import { ProtectedRoutes } from "./protectedRoutes.component";
export const ConnectedProtectedRoutes = connect(state => ({
token: state.token
}))(ProtectedRoutes);
@@ -0,0 +1,9 @@
/* @flow */
import axios from "axios";
export const loginService = (user, password) =>
axios.post(
`http://localhost:8443/gettoken`,
`user=${user}&password=${password}`
);
+21
View File
@@ -0,0 +1,21 @@
/* @flow */
import { createStore, applyMiddleware } from "redux";
import thunk from "redux-thunk";
import { createLogger } from "redux-logger";
import { composeWithDevTools } from "redux-devtools-extension";
import { connectRouter, routerMiddleware } from "connected-react-router";
import { createBrowserHistory } from "history";
import { reducer } from "./login.reducer";
const logger = createLogger({ duration: true });
export const history = createBrowserHistory();
export const store = createStore(
connectRouter(history)(reducer),
composeWithDevTools(
applyMiddleware(routerMiddleware(history), thunk, logger)
)
);