diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..97df8c0 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,12 @@ +FROM node:11 + +RUN mkdir /usr/src/app +WORKDIR /usr/src/app + +COPY . /usr/src/app + +RUN yarn + +CMD ["npm", "start"] + +EXPOSE 3000 diff --git a/swapi/actions.js b/swapi/actions.js index 9476209..00084fd 100644 --- a/swapi/actions.js +++ b/swapi/actions.js @@ -3,10 +3,32 @@ 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 loadData(url, loadedData = []) { + const 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; + } + + const loadData = (url, loadedData = []) => { dispatch({ type: types.START_LOAD }); return fetch(url) @@ -35,10 +57,73 @@ export const onChooseEndpoint = (endpoint) => (dispatch, getState) => { } - return loadData('https://swapi.co/api/' + endpoint + '/') + const 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 Promise.all([loadData(url), 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(), 'en', {sensitivity: 'base'})); + + dispatch({ + type: types.SET_DATA, + payload: { + endpoint: current_state.endpoint, + data: data, + } + }); + + dispatch({ type: types.DONE_LOAD }); +}; + +export const onExpandToggle = (url) => (dispatch, getState) => { + dispatch({ type: types.START_LOAD }); + + dispatch({ + type: types.EXPAND_ITEM, + url: url, + }); + + 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/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-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..03f65f1 100644 --- a/swapi/components/item-display.js +++ b/swapi/components/item-display.js @@ -1,37 +1,38 @@ 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, - }) + }), + isExpanded: PropTypes.bool, + loading: PropTypes.bool.isRequired, + getIconName: PropTypes.func.isRequired, }; - state = { - isExpanded: false, - }; - - handleExpandToggle = () => { - this.setState((state) => ({ isExpanded: !state.isExpanded })); - }; + handleExpandToggle = () => this.props.onExpandToggle(this.props.children.url); 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..65e1e8e 100644 --- a/swapi/components/millennium-falcon-connected.js +++ b/swapi/components/millennium-falcon-connected.js @@ -2,23 +2,52 @@ 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); + + const categoryList = [ + {key: 'people', name: 'People'}, + {key: 'films', name: 'Films'}, + {key: 'planets', name: 'Planets'}, + {key: 'species', name: 'Species'}, + {key: 'starships', name: 'Starships'}, + {key: 'vehicles', name: 'Vehicles'} + ]; + return { list, + categoryList, 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..9ea6c44 100644 --- a/swapi/components/millennium-falcon.js +++ b/swapi/components/millennium-falcon.js @@ -1,20 +1,32 @@ import React from 'react'; import PropTypes from 'prop-types'; import classNames from 'classnames'; -import ItemDisplay from './item-display'; +import ItemDisplayConnected from './item-display-connected'; +import CategoryDisplayConnected from './category-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, + categoryList: PropTypes.arrayOf(PropTypes.shape({ + key: PropTypes.string.isRequired, + name: PropTypes.string.isRequired, + })).isRequired, side: PropTypes.string, }; + handleOrderList = (e) => this.props.onOrderList(e.target.value); + + handleExpandAll = () => this.props.onExpandAll(true); + handleCollapseAll = () => this.props.onExpandAll(false); + render() { - const { loading, list, onChooseEndpoint } = this.props; + const { loading, hasData, list, categoryList, onChooseEndpoint, onOrderList, onExpandAll, endpoint, orderOptions } = this.props; const iconClass = classNames('fa', { 'fa-refresh': loading, @@ -25,12 +37,23 @@ export default class MillenniumFalcon extends React.PureComponent { return (

What do you want to see?

- - + {categoryList.map((category) => {category})} +
+

+
+ + +
+
+


- - {list.map((item) => )} +
{item}
+ + {list.map((item) => )}
{item}
); diff --git a/swapi/store.js b/swapi/store.js index 1734d06..6ba5c29 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 url = action.url; + const expandAll = action.expandAll; + + var data = state.data[state.endpoint]; + data = data.map((item) => { + if (url !== undefined && item.url === 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';