From 39911ba1916bd4aa36e3f6cb9ec159dcd4196ecd Mon Sep 17 00:00:00 2001 From: Camilla Horne Larsen Date: Sun, 24 Feb 2019 14:35:14 +0100 Subject: [PATCH 1/7] Finished the challenges and added some additional changes: - Added planets, species, starships, and vehicles - Collapse/Expand all buttons - Order by keys extracted from the schema - Added the Dockerfile --- Dockerfile | 12 +++ swapi/actions.js | 92 ++++++++++++++++++- swapi/components/item-display-connected.js | 48 ++++++++++ swapi/components/item-display.js | 27 +++--- .../components/millennium-falcon-connected.js | 27 +++++- swapi/components/millennium-falcon.js | 35 +++++-- swapi/store.js | 38 ++++++++ swapi/types.js | 2 + 8 files changed, 256 insertions(+), 25 deletions(-) create mode 100644 Dockerfile create mode 100644 swapi/components/item-display-connected.js diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..33e36d6 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,12 @@ +FROM node:8 + +RUN mkdir /usr/src/app +WORKDIR /usr/src/app + +COPY . /usr/src/app + +ENV PATH /usr/src/app/node_modules/.bin:$PATH + +RUN yarn + +CMD ["npm", "start"] diff --git a/swapi/actions.js b/swapi/actions.js index 9476209..8a86437 100644 --- a/swapi/actions.js +++ b/swapi/actions.js @@ -3,9 +3,31 @@ import * as types from './types'; const sleep = (seconds, withValue) => new Promise(resolve => setTimeout(resolve, seconds * 1000, withValue)); export const onChooseEndpoint = (endpoint) => (dispatch, getState) => { + const url = 'https://swapi.co/api/' + endpoint + '/'; + dispatch({ type: types.PICK_ENDPOINT, payload: endpoint }); dispatch({ type: types.START_LOAD }); + function isCached() { + var current_state = getState(); + var data = current_state.data[endpoint]; + + if (data === undefined || data.length === 0) + return false; + + dispatch({ + type: types.SET_DATA, + payload: { + endpoint: endpoint, + data: data, + } + }); + + dispatch({ type: types.DONE_LOAD }); + + return true; + } + function loadData(url, loadedData = []) { dispatch({ type: types.START_LOAD }); @@ -35,10 +57,78 @@ export const onChooseEndpoint = (endpoint) => (dispatch, getState) => { } - return loadData('https://swapi.co/api/' + endpoint + '/') + function loadSchema(url) { + dispatch({ type: types.START_LOAD }); + + return fetch(url + 'schema') + .then((response) => response.json()) + .then((json) => { + dispatch({ + type: types.SET_SCHEMA, + payload: { + endpoint: endpoint, + data: json, + } + }); + + dispatch({ type: types.DONE_LOAD }); + }) + .catch(() => console.error('Oops')) + } + + if (isCached()) + return; + + return loadData(url) + .catch((err) => { + console.error(err); + alert('It went wrong.') + }) + .then(() => loadSchema(url)) .catch((err) => { console.error(err); alert('It went wrong.') }) .then(() => dispatch({ type: types.DONE_LOAD })) }; + +export const onOrderList = (selectedKey) => (dispatch, getState) => { + dispatch({ type: types.START_LOAD }); + + var current_state = getState(); + + var data = [].concat(current_state.data[current_state.endpoint]); + data.sort((a, b) => (a[selectedKey].toString()).localeCompare(b[selectedKey].toString())); + + dispatch({ + type: types.SET_DATA, + payload: { + endpoint: current_state.endpoint, + data: data, + } + }); + + dispatch({ type: types.DONE_LOAD }); +}; + +export const onExpandToggle = (itemDisplay) => (dispatch, getState) => { + dispatch({ type: types.START_LOAD }); + + dispatch({ + type: types.EXPAND_ITEM, + itemDisplay: itemDisplay, + }); + + dispatch({ type: types.DONE_LOAD }); +}; + +export const onExpandAll = (expand) => (dispatch, getState) => { + dispatch({ type: types.START_LOAD }); + + dispatch({ + type: types.EXPAND_ITEM, + expandAll: expand, + }); + + dispatch({ type: types.DONE_LOAD }); +}; diff --git a/swapi/components/item-display-connected.js b/swapi/components/item-display-connected.js new file mode 100644 index 0000000..fe608a4 --- /dev/null +++ b/swapi/components/item-display-connected.js @@ -0,0 +1,48 @@ +import { bindActionCreators} from 'redux'; +import { connect } from 'react-redux'; +import ItemDisplay from '../components/item-display'; + +import { onExpandToggle } from '../actions'; + +const getIconName = (data) => { + switch (data.kind) { + case 'people': + if (data.gender == 'female') + return 'fa-female' + else if (data.gender == 'male') + return 'fa-male' + else + return 'fa-android' + case 'films': + return 'fa-film' + case 'planets': + return 'fa-globe' + case 'species': + return 'fa-user' + case 'starships': + return 'fa-plane' + case 'vehicles': + return 'fa-bus' + } +} + +const mapStateToProps = (state, itemDisplay) => { + const data = Object.assign({}, itemDisplay.children); + const isExpanded = data.isExpanded; + delete data.isExpanded; + + return { + data, + isExpanded, + loading: state.operations > 0, + getIconName + } +}; + +const mapDispatchToProps = (dispatch) => { + return bindActionCreators({ + onExpandToggle, + }, dispatch); +}; + +export default connect(mapStateToProps, mapDispatchToProps)(ItemDisplay); diff --git a/swapi/components/item-display.js b/swapi/components/item-display.js index 67d26c4..e878d99 100644 --- a/swapi/components/item-display.js +++ b/swapi/components/item-display.js @@ -1,37 +1,36 @@ import React from 'react'; import PropTypes from 'prop-types'; +import classNames from 'classnames'; export default class ItemDisplay extends React.PureComponent { static propTypes = { + onExpandToggle: PropTypes.func.isRequired, children: PropTypes.shape({ url: PropTypes.string.isRequired, name: PropTypes.string.isRequired, kind: PropTypes.string.isRequired, - }) - }; - - state = { - isExpanded: false, - }; - - handleExpandToggle = () => { - this.setState((state) => ({ isExpanded: !state.isExpanded })); + }), + isExpanded: PropTypes.bool, + loading: PropTypes.bool.isRequired, + getIconName: PropTypes.func.isRequired, }; render() { - const { children: { name }, onExpandToggle} = this.props; - const { isExpanded } = this.state; + const { data, onExpandToggle, isExpanded, loading, getIconName } = this.props; return ( - {' '} - {name} - { isExpanded ?
{JSON.stringify(this.props.children, null, 4)}
: null} + + {' '} + {data.kind === 'films' ? 'Episode ' + data.episode_id + ': ' : null} + {data.name} + {isExpanded ?
{JSON.stringify(data, null, 4)}
: null}
); } } diff --git a/swapi/components/millennium-falcon-connected.js b/swapi/components/millennium-falcon-connected.js index b2e74f3..518dc72 100644 --- a/swapi/components/millennium-falcon-connected.js +++ b/swapi/components/millennium-falcon-connected.js @@ -2,23 +2,42 @@ import { bindActionCreators} from 'redux'; import { connect } from 'react-redux'; import MillenniumFalcon from '../components/millennium-falcon'; -import { onChooseEndpoint } from '../actions'; +import { onChooseEndpoint, onOrderList, onExpandAll } from '../actions'; + +const getSchemaProperties = (schema, endpoint) => { + return (schema && schema[endpoint] && schema[endpoint].properties) || []; +} + +const getOrderableSchemaProperties = (schema, endpoint) => { + var schemaProperties = getSchemaProperties(schema, endpoint); + return Object.keys(schemaProperties) + .map((propertyKey) => { + var value = schemaProperties[propertyKey]; + value['key'] = propertyKey; + return value; + }) + .filter((property) => (property.type === 'string' || property.type === 'integer') && property.format === undefined); +} const mapStateToProps = (state) => { - const { endpoint, data } = state; + const { endpoint, data, schema } = state; const list = (data && data[endpoint]) || []; - console.log(state.operations); + return { list, endpoint, loading: state.operations > 0, + hasData: list.length > 0, + orderOptions: getOrderableSchemaProperties(schema, endpoint), } }; const mapDispatchToProps = (dispatch) => { return bindActionCreators({ - onChooseEndpoint: onChooseEndpoint, + onChooseEndpoint, + onOrderList, + onExpandAll, }, dispatch); }; diff --git a/swapi/components/millennium-falcon.js b/swapi/components/millennium-falcon.js index 72b7884..68de21e 100644 --- a/swapi/components/millennium-falcon.js +++ b/swapi/components/millennium-falcon.js @@ -1,12 +1,14 @@ import React from 'react'; import PropTypes from 'prop-types'; import classNames from 'classnames'; -import ItemDisplay from './item-display'; +import ItemDisplayConnected from './item-display-connected'; export default class MillenniumFalcon extends React.PureComponent { static propTypes = { onChooseEndpoint: PropTypes.func.isRequired, + onOrderList: PropTypes.func.isRequired, + onExpandAll: PropTypes.func.isRequired, list: PropTypes.arrayOf(PropTypes.shape({ url: PropTypes.string.isRequired, })).isRequired, @@ -14,7 +16,7 @@ export default class MillenniumFalcon extends React.PureComponent { }; render() { - const { loading, list, onChooseEndpoint } = this.props; + const { loading, hasData, list, onChooseEndpoint, onOrderList, onExpandAll, endpoint, orderOptions } = this.props; const iconClass = classNames('fa', { 'fa-refresh': loading, @@ -22,15 +24,36 @@ export default class MillenniumFalcon extends React.PureComponent { 'fa-star': !loading, }); + const getEndpointButtonClassNames = (buttonEndpoint) => classNames('btn', { + 'btn-default': endpoint != buttonEndpoint, + 'btn-success': endpoint == buttonEndpoint + }); + return (

What do you want to see?

- - + + + + + + +
+

+
+ + +
+
+


- - {list.map((item) => )} +
{item}
+ + {list.map((item) => )}
{item}
); diff --git a/swapi/store.js b/swapi/store.js index 1734d06..c5aa5d5 100644 --- a/swapi/store.js +++ b/swapi/store.js @@ -7,6 +7,7 @@ const defaultData = { data: { /** will contain response from SWAPI, indexed by endpiont */ }, + schema: {}, operations: 0, }; @@ -23,6 +24,17 @@ const reducer = (state = {}, action) => { } } + case types.SET_SCHEMA: { + const { endpoint, data } = action.payload; + return { + ...state, + schema: { + ...state.schema, + [endpoint]: data, + } + } + } + case types.PICK_ENDPOINT: { return { ...state, @@ -43,6 +55,32 @@ const reducer = (state = {}, action) => { operations: state.operations - 1, } } + + case types.EXPAND_ITEM: { + const itemDisplay = action.itemDisplay; + const expandAll = action.expandAll; + + var data = state.data[state.endpoint]; + data = data.map((item) => { + if (itemDisplay !== undefined && item.url === itemDisplay.props.children.url) { + item = Object.assign({}, item); + item.isExpanded = item.isExpanded !== true; + } + else if (expandAll !== undefined && (item.isExpanded === true) !== expandAll) { + item = Object.assign({}, item); + item.isExpanded = expandAll === true; + } + return item; + }); + + return { + ...state, + data: { + ...state.data, + [state.endpoint]: data + } + } + } } return state; diff --git a/swapi/types.js b/swapi/types.js index da5a8e4..89527db 100644 --- a/swapi/types.js +++ b/swapi/types.js @@ -2,3 +2,5 @@ export const PICK_ENDPOINT = 'PICK_ENDPOINT'; export const SET_DATA = 'SET_DATA'; export const START_LOAD = 'START_LOAD'; export const DONE_LOAD = 'DONE_LOAD'; +export const EXPAND_ITEM = 'EXPAND_ITEM'; +export const SET_SCHEMA = 'SET_SCHEMA'; From d4c6cb695974f7433717b3715fded7016a1c0a5a Mon Sep 17 00:00:00 2001 From: Camilla Horne Larsen Date: Mon, 25 Feb 2019 13:28:53 +0100 Subject: [PATCH 2/7] Added locale and sensitivity to localeCompare and a fallback in case the value is null or undefined. --- swapi/actions.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/swapi/actions.js b/swapi/actions.js index 8a86437..c55a871 100644 --- a/swapi/actions.js +++ b/swapi/actions.js @@ -98,7 +98,7 @@ export const onOrderList = (selectedKey) => (dispatch, getState) => { var current_state = getState(); var data = [].concat(current_state.data[current_state.endpoint]); - data.sort((a, b) => (a[selectedKey].toString()).localeCompare(b[selectedKey].toString())); + data.sort((a, b) => ((a[selectedKey] || '').toString()).localeCompare((b[selectedKey] || '').toString(), 'en', {sensitivity: 'base'})); dispatch({ type: types.SET_DATA, From 76d63195eff124c150767a9a4eb5e290469a50ae Mon Sep 17 00:00:00 2001 From: Camilla Horne Larsen Date: Mon, 25 Feb 2019 13:36:03 +0100 Subject: [PATCH 3/7] Converted functions to const --- swapi/actions.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/swapi/actions.js b/swapi/actions.js index c55a871..678bd74 100644 --- a/swapi/actions.js +++ b/swapi/actions.js @@ -8,7 +8,7 @@ export const onChooseEndpoint = (endpoint) => (dispatch, getState) => { dispatch({ type: types.PICK_ENDPOINT, payload: endpoint }); dispatch({ type: types.START_LOAD }); - function isCached() { + const isCached = () => { var current_state = getState(); var data = current_state.data[endpoint]; @@ -28,7 +28,7 @@ export const onChooseEndpoint = (endpoint) => (dispatch, getState) => { return true; } - function loadData(url, loadedData = []) { + const loadData = (url, loadedData = []) => { dispatch({ type: types.START_LOAD }); return fetch(url) @@ -57,7 +57,7 @@ export const onChooseEndpoint = (endpoint) => (dispatch, getState) => { } - function loadSchema(url) { + const loadSchema = (url) => { dispatch({ type: types.START_LOAD }); return fetch(url + 'schema') From 514e60c54290eb2116e63317bd159b7334666d3a Mon Sep 17 00:00:00 2001 From: Camilla Horne Larsen Date: Mon, 25 Feb 2019 13:59:41 +0100 Subject: [PATCH 4/7] Converted string concatenations to template literals --- swapi/actions.js | 2 +- swapi/components/item-display.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/swapi/actions.js b/swapi/actions.js index 678bd74..9610e75 100644 --- a/swapi/actions.js +++ b/swapi/actions.js @@ -3,7 +3,7 @@ import * as types from './types'; const sleep = (seconds, withValue) => new Promise(resolve => setTimeout(resolve, seconds * 1000, withValue)); export const onChooseEndpoint = (endpoint) => (dispatch, getState) => { - const url = 'https://swapi.co/api/' + endpoint + '/'; + const url = `https://swapi.co/api/${endpoint}/`; dispatch({ type: types.PICK_ENDPOINT, payload: endpoint }); dispatch({ type: types.START_LOAD }); diff --git a/swapi/components/item-display.js b/swapi/components/item-display.js index e878d99..5847ed6 100644 --- a/swapi/components/item-display.js +++ b/swapi/components/item-display.js @@ -28,7 +28,7 @@ export default class ItemDisplay extends React.PureComponent { {' '} {' '} - {data.kind === 'films' ? 'Episode ' + data.episode_id + ': ' : null} + {data.kind === 'films' ? `Episode ${data.episode_id}: ` : null} {data.name} {isExpanded ?
{JSON.stringify(data, null, 4)}
: null} ); From 674bd38f49210c869275144141d090d0c73dd67a Mon Sep 17 00:00:00 2001 From: Camilla Horne Larsen Date: Mon, 25 Feb 2019 20:58:14 +0100 Subject: [PATCH 5/7] Removed arrow functions in JSX --- swapi/actions.js | 4 +-- .../components/category-display-connected.js | 20 +++++++++++++ swapi/components/category-display.js | 29 ++++++++++++++++++ swapi/components/item-display.js | 4 ++- .../components/millennium-falcon-connected.js | 10 +++++++ swapi/components/millennium-falcon.js | 30 +++++++++---------- swapi/store.js | 4 +-- 7 files changed, 81 insertions(+), 20 deletions(-) create mode 100644 swapi/components/category-display-connected.js create mode 100644 swapi/components/category-display.js diff --git a/swapi/actions.js b/swapi/actions.js index 9610e75..bdef158 100644 --- a/swapi/actions.js +++ b/swapi/actions.js @@ -111,12 +111,12 @@ export const onOrderList = (selectedKey) => (dispatch, getState) => { dispatch({ type: types.DONE_LOAD }); }; -export const onExpandToggle = (itemDisplay) => (dispatch, getState) => { +export const onExpandToggle = (url) => (dispatch, getState) => { dispatch({ type: types.START_LOAD }); dispatch({ type: types.EXPAND_ITEM, - itemDisplay: itemDisplay, + url: url, }); dispatch({ type: types.DONE_LOAD }); diff --git a/swapi/components/category-display-connected.js b/swapi/components/category-display-connected.js new file mode 100644 index 0000000..224c82f --- /dev/null +++ b/swapi/components/category-display-connected.js @@ -0,0 +1,20 @@ +import { bindActionCreators} from 'redux'; +import { connect } from 'react-redux'; +import CategoryDisplay from '../components/category-display'; + +import { onChooseEndpoint } from '../actions'; + +const mapStateToProps = (state, categoryDisplay) => { + return { + loading: state.operations > 0, + endpoint: state.endpoint + } +}; + +const mapDispatchToProps = (dispatch) => { + return bindActionCreators({ + onChooseEndpoint, + }, dispatch); +}; + +export default connect(mapStateToProps, mapDispatchToProps)(CategoryDisplay); diff --git a/swapi/components/category-display.js b/swapi/components/category-display.js new file mode 100644 index 0000000..43f42c2 --- /dev/null +++ b/swapi/components/category-display.js @@ -0,0 +1,29 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; + +export default class CategoryDisplay extends React.PureComponent { + + static propTypes = { + onChooseEndpoint: PropTypes.func.isRequired, + children: PropTypes.shape({ + key: PropTypes.string.isRequired, + name: PropTypes.string.isRequired, + }).isRequired, + endpoint: PropTypes.string, + loading: PropTypes.bool.isRequired, + }; + + handleChooseEndpoint = () => this.props.onChooseEndpoint(this.props.children.key); + + render() { + const { children: { key, name }, loading, endpoint } = this.props; + + const getEndpointButtonClassNames = classNames('btn', { + 'btn-default': key !== endpoint, + 'btn-success': key === endpoint + }); + + return (); + } +} diff --git a/swapi/components/item-display.js b/swapi/components/item-display.js index 5847ed6..03f65f1 100644 --- a/swapi/components/item-display.js +++ b/swapi/components/item-display.js @@ -16,11 +16,13 @@ export default class ItemDisplay extends React.PureComponent { getIconName: PropTypes.func.isRequired, }; + handleExpandToggle = () => this.props.onExpandToggle(this.props.children.url); + render() { const { data, onExpandToggle, isExpanded, loading, getIconName } = this.props; return ( - - - - - - + {categoryList.map((category) => {category})}

- - + +
- {orderOptions !== undefined && orderOptions.map((orderOption) => )} diff --git a/swapi/store.js b/swapi/store.js index c5aa5d5..6ba5c29 100644 --- a/swapi/store.js +++ b/swapi/store.js @@ -57,12 +57,12 @@ const reducer = (state = {}, action) => { } case types.EXPAND_ITEM: { - const itemDisplay = action.itemDisplay; + const url = action.url; const expandAll = action.expandAll; var data = state.data[state.endpoint]; data = data.map((item) => { - if (itemDisplay !== undefined && item.url === itemDisplay.props.children.url) { + if (url !== undefined && item.url === url) { item = Object.assign({}, item); item.isExpanded = item.isExpanded !== true; } From 4ac216fe9e469b87ef54682e38bbcb93816d61da Mon Sep 17 00:00:00 2001 From: Camilla Horne Larsen Date: Mon, 25 Feb 2019 21:06:57 +0100 Subject: [PATCH 6/7] Load schema while loading data. --- swapi/actions.js | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/swapi/actions.js b/swapi/actions.js index bdef158..00084fd 100644 --- a/swapi/actions.js +++ b/swapi/actions.js @@ -79,12 +79,7 @@ export const onChooseEndpoint = (endpoint) => (dispatch, getState) => { if (isCached()) return; - return loadData(url) - .catch((err) => { - console.error(err); - alert('It went wrong.') - }) - .then(() => loadSchema(url)) + return Promise.all([loadData(url), loadSchema(url)]) .catch((err) => { console.error(err); alert('It went wrong.') From 35e49a5c4a94c5e379225755d07285d1ef6cffe8 Mon Sep 17 00:00:00 2001 From: Camilla Horne Larsen Date: Mon, 25 Feb 2019 22:25:10 +0100 Subject: [PATCH 7/7] Updated the Dockerfile --- Dockerfile | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Dockerfile b/Dockerfile index 33e36d6..97df8c0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,12 +1,12 @@ -FROM node:8 +FROM node:11 RUN mkdir /usr/src/app WORKDIR /usr/src/app COPY . /usr/src/app -ENV PATH /usr/src/app/node_modules/.bin:$PATH - RUN yarn CMD ["npm", "start"] + +EXPOSE 3000