diff --git a/.babelrc b/.babelrc new file mode 100644 index 0000000000..7dfe4e9f26 --- /dev/null +++ b/.babelrc @@ -0,0 +1,3 @@ +{ + "presets": ["react", "es2015", "stage-1"] +} diff --git a/index.html b/index.html new file mode 100644 index 0000000000..e8d2fc1f78 --- /dev/null +++ b/index.html @@ -0,0 +1,8 @@ + + + + +
+ + + diff --git a/package.json b/package.json index 362aa65404..764522e6b3 100644 --- a/package.json +++ b/package.json @@ -1,14 +1,35 @@ { "name": "react-coding-challenge", "version": "1.0.0", - "description": "A package containing a simple dummy book API, which should be used in the challenge", + "description": "Challenge accepted", + "main": "index.js", "scripts": { - "start": "json-server --port 3010 --watch api.json" + "start": "json-server --port 3010 --watch api.json &node ./node_modules/webpack-dev-server/bin/webpack-dev-server.js" }, - "keywords": ["react challenge", "tryouts", "react developer", "hiring"], - "author": "Adapt A/S", + "keywords": [ + "react challenge", + "tryouts", + "react developer", + "hiring" + ], + "author": "velicv", "license": "ISC", "devDependencies": { - "json-server": "0.10.1" + "babel-core": "^6.24.1", + "babel-loader": "^7.0.0", + "babel-preset-es2015": "^6.24.1", + "babel-preset-react": "^6.24.1", + "json-server": "0.10.1", + "webpack": "^2.6.1", + "webpack-dev-server": "^2.4.5" + }, + "dependencies": { + "axios": "^0.16.2", + "babel-preset-stage-1": "^6.24.1", + "react": "^15.5.4", + "react-dom": "^15.5.4", + "react-redux": "^5.0.5", + "redux": "^3.6.0", + "redux-promise": "^0.5.3" } } diff --git a/src/actions/index.js b/src/actions/index.js new file mode 100644 index 0000000000..91d77bc48e --- /dev/null +++ b/src/actions/index.js @@ -0,0 +1,52 @@ + +import axios from 'axios'; + +const ROOT_URL = 'http://localhost:3010'; + +export const FETCH_BOOKS = 'FETCH_BOOKS'; +export const SELECT_BOOK = 'SELECT_BOOK'; +export const SAVE_BOOK = 'SAVE_BOOK'; +export const FETCH_SUBJECTS = 'FETCH_SUBJECTS'; + +export function fetchSubjects() { + + const request = axios.get(`${ROOT_URL}/subjects`); + + return { + type: FETCH_SUBJECTS, + payload: request + }; +} + +export function fetchBooks(subject) { + + let payload; + + if (subject) { + payload = axios.get(`${ROOT_URL}/books?subjects_like=${subject}`); + } else { + payload = { data: [] }; + } + + return { + type: FETCH_BOOKS, + payload: payload + }; +} + +export function selectBook(id) { + return { + type: SELECT_BOOK, + payload: id + }; +} + +export function saveBook(book) { + + const request = axios.put(`${ROOT_URL}/books/${book.id}`, book); + + return { + type: SAVE_BOOK, + payload: request + }; +} diff --git a/src/components/app.js b/src/components/app.js new file mode 100644 index 0000000000..b79f76fdb8 --- /dev/null +++ b/src/components/app.js @@ -0,0 +1,17 @@ +import React, { Component } from 'react'; + +import SubjectSelection from '../containers/subjectSelection'; +import BookSelection from '../containers/bookSelection'; +import BookForm from '../containers/bookForm'; + +export default class App extends Component { + render() { + return ( +
+ + + +
+ ); + } +} diff --git a/src/components/form/formFieldComponent.js b/src/components/form/formFieldComponent.js new file mode 100644 index 0000000000..313fed05b2 --- /dev/null +++ b/src/components/form/formFieldComponent.js @@ -0,0 +1,30 @@ +import React, { Component } from 'react'; + +export default class FormFieldComponent extends Component { + + getDefaultValue() { + return ''; + } + + constructor(props) { + super(props); + this.state = { value: props.value || this.getDefaultValue() }; + } + + handlOnChange() { + + const value = this.formValue(...arguments); + + if (this.props.handleOnChange) { + this.props.handleOnChange(value); + } + + this.setState({ value: value }); + } + + componentWillReceiveProps(nextProps) { + this.setState({ + value: nextProps.value || this.getDefaultValue() + }); + } +} diff --git a/src/components/form/inputArray.js b/src/components/form/inputArray.js new file mode 100644 index 0000000000..ff704a1ccc --- /dev/null +++ b/src/components/form/inputArray.js @@ -0,0 +1,54 @@ +import React from 'react'; + +import FormFieldComponent from './formFieldComponent'; +import TextField from './textField'; + +export default class InputArray extends FormFieldComponent { + + getDefaultValue() { + return []; + } + + formValue(index, newValue) { + const value = [...this.state.value]; + value[index] = newValue; + return value; + } + + onAddClick() { + + let defaultValue = ''; + + if (this.props.options && this.props.options.getDefaultValue) { + defaultValue = this.props.options.getDefaultValue(); + } + + this.handlOnChange(this.state.value.length, defaultValue) + } + + renderItem(itemValue, index) { + + const value = this.props.value || []; + const options = this.props.options || {}; + const Type = options.type ? options.type : TextField; + + return ( + + ); + } + + render() { + return ( + +
+ {this.state.value.map(this.renderItem.bind(this))} + Add +
+ ); + } +} diff --git a/src/components/form/inputSet.js b/src/components/form/inputSet.js new file mode 100644 index 0000000000..9303ece60a --- /dev/null +++ b/src/components/form/inputSet.js @@ -0,0 +1,47 @@ +import React from 'react'; + +import FormFieldComponent from './formFieldComponent'; + +const labelStyle = { display: 'inline-block', width: 200 }; +const setStyle = { paddingLeft: 20, border: '1px solid lightgray'}; + +export default class InputSet extends FormFieldComponent { + + getDefaultValue() { + return {}; + } + + formValue(field, newValue) { + const value = {...this.state.value}; + value[field] = newValue; + return value; + } + + renderField(fieldConfig) { + + const value = this.state.value; + return ( +
+ + +
+ ); + } + + render() { + + if (!this.props.options || !this.props.options.fields) { + return null; + } + + return ( +
+ {this.props.options.fields.map(this.renderField.bind(this))} +
+ ); + } +} diff --git a/src/components/form/selectField.js b/src/components/form/selectField.js new file mode 100644 index 0000000000..fab18b41fc --- /dev/null +++ b/src/components/form/selectField.js @@ -0,0 +1,24 @@ +import React from 'react'; +import FormFieldComponent from './formFieldComponent'; + +export default class SelectField extends FormFieldComponent { + + formValue(ev) { + return ev.target.value; + } + + renderOptions(option) { + return ( + + ); + } + + render() { + return ( + + ); + } +} diff --git a/src/components/form/textField.js b/src/components/form/textField.js new file mode 100644 index 0000000000..6eac5228a2 --- /dev/null +++ b/src/components/form/textField.js @@ -0,0 +1,15 @@ +import React from 'react'; +import FormFieldComponent from './formFieldComponent'; + +export default class TextField extends FormFieldComponent { + + formValue(ev) { + return ev.target.value; + } + + render() { + return ( + + ); + } +} diff --git a/src/containers/bookForm.js b/src/containers/bookForm.js new file mode 100644 index 0000000000..d044738346 --- /dev/null +++ b/src/containers/bookForm.js @@ -0,0 +1,103 @@ +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { bindActionCreators } from 'redux'; + +import { saveBook } from '../actions/index'; +import TextField from '../components/form/textField'; +import InputSet from '../components/form/inputSet'; +import InputArray from '../components/form/inputArray'; +import SelectField from '../components/form/selectField'; + +class BookForm extends Component { + + constructor(props) { + super(props); + this.state = { book: props.book }; + } + + getFieldConfig() { + return [ + { field: 'title', type: TextField, title: 'Title' }, + { field: 'id', type: TextField, title: 'Id' }, + { field: 'download_count', type: TextField, title: 'Download count' }, + { field: 'media_type', type: TextField, title: 'Media Type' }, + { field: 'subjects', type: InputArray, title: 'Subjects', options : { + type: SelectField, + options: { values: this.props.subjects } + } }, + { field: 'authors', type: InputArray, title: 'Authors', options: { + type: InputSet, + getDefaultValue: () => { return {}; }, + options: { + fields: [ + { field: 'birth_year', type: TextField, title: 'Birth year' }, + { field: 'death_year', type: TextField, title: 'Death year' }, + { field: 'name', type: TextField, title: 'Name' } + ] + } + } }, + { field: 'bookshelves', type: InputArray, title: 'Bookshelves' }, + { field: 'languages', type: InputArray, title: 'Languages' }, + { field: 'formats', type: InputSet, title: 'Formats', options: { + fields: [ + { field: 'text/plain; charset=utf-8', type: TextField, title: 'UTF-8' }, + { field: 'application/pdf', type: TextField, title: 'PDF' }, + { field: 'application/rdf+xml', type: TextField, title: 'RDF+XML' }, + { field: 'application/x-mobipocket-ebook', type: TextField, title: 'Mobipocket' }, + { field: 'application/epub+zip', type: TextField, title: 'Epub' }, + { field: 'text/plain; charset=us-ascii', type: TextField, title: 'ASCII' }, + { field: 'text/html; charset=utf-8', type: TextField, title: 'HTML' } + ] + } } + ] + } + + handleOnSubmit(ev) { + this.props.saveBook(this.state.book); + ev.preventDefault(); + } + + handleOnChange(value) { + this.setState({ book: value }); + } + + componentWillReceiveProps(nextProps) { + + let book; + + if (nextProps.book) { + book = JSON.parse(JSON.stringify(nextProps.book)); + } + + this.setState({ + book: book + }) + } + + render() {{} + + if (!this.state.book) { + return null; + } + + return ( +
+ + + + ); + } +} + +function mapStateToProps(state) { + return { + book: state.books.selected, + subjects: state.subjects + }; +} + +export default connect(mapStateToProps, { saveBook })(BookForm); diff --git a/src/containers/bookSelection.js b/src/containers/bookSelection.js new file mode 100644 index 0000000000..7455b76836 --- /dev/null +++ b/src/containers/bookSelection.js @@ -0,0 +1,49 @@ +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { bindActionCreators } from 'redux'; + +import { selectBook } from '../actions/index'; + +class BookSelection extends Component { + + handleOnChange(event) { + this.props.selectBook(event.target.value); + } + + renderOptions() { + return Object.keys(this.props.books).map((key) => { + + const book = this.props.books[key]; + return ( + + ); + }) + } + + render() { + + if (Object.keys(this.props.books).length < 1) { + return null; + } + + return ( +
+ + +
+ ); + } +} + +function mapStateToProps(state) { + return { + books: state.books.all + }; +} + +export default connect(mapStateToProps, { selectBook })(BookSelection); diff --git a/src/containers/subjectSelection.js b/src/containers/subjectSelection.js new file mode 100644 index 0000000000..212cfc36ed --- /dev/null +++ b/src/containers/subjectSelection.js @@ -0,0 +1,49 @@ +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { bindActionCreators } from 'redux'; + +import { fetchSubjects, fetchBooks } from '../actions/index'; + +class SubjectSelection extends Component { + + componentWillMount() { + this.props.fetchSubjects(); + } + + handleOnChange(event) { + this.props.fetchBooks(event.target.value); + } + + renderOptions() { + return this.props.subjects.map((subject) => { + return ( + + ); + }) + } + + render() { + return ( +
+ + +
+ ); + } +} + +function mapStateToProps(state) { + return { + subjects: state.subjects + }; +} + +export default connect( + mapStateToProps, + { fetchSubjects, fetchBooks } +)(SubjectSelection); diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000000..ca162d53ec --- /dev/null +++ b/src/index.js @@ -0,0 +1,18 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; +import { Provider } from 'react-redux'; +import { createStore, applyMiddleware } from 'redux'; +import promise from 'redux-promise'; + +import App from './components/app'; +import reducers from './reducers'; + +const createStoreWithMiddleware = applyMiddleware( + promise +)(createStore); + +ReactDOM.render( + + + + , document.querySelector('.container')); diff --git a/src/reducers/books.js b/src/reducers/books.js new file mode 100644 index 0000000000..a9e210dc34 --- /dev/null +++ b/src/reducers/books.js @@ -0,0 +1,34 @@ + +import {FETCH_BOOKS, SELECT_BOOK, SAVE_BOOK} from '../actions/index'; + +const INITIAL_STATE = { all: {}, selected: null }; + +function mapBookArrayToObject(array) { + + const obj = {}; + + array.forEach((item) => { + obj[item.id] = item; + }); + + return obj; +} + +export default function (state = INITIAL_STATE, action) { + + switch(action.type) { + + case FETCH_BOOKS: + return {...state, all: mapBookArrayToObject(action.payload.data) }; + case SELECT_BOOK: + return {...state, selected: state.all[action.payload] || null }; + case SAVE_BOOK: + const savedBook = action.payload.data; + return { + all: {...state.all, [savedBook.id]: savedBook}, + selected: savedBook, + }; + default: + return state; + } +} diff --git a/src/reducers/index.js b/src/reducers/index.js new file mode 100644 index 0000000000..f39c926b28 --- /dev/null +++ b/src/reducers/index.js @@ -0,0 +1,12 @@ + +import { combineReducers } from 'redux'; + +import SubjectsReducer from './subjects'; +import BooksReducer from './books'; + +const rootReducer = combineReducers({ + subjects: SubjectsReducer, + books: BooksReducer +}); + +export default rootReducer; diff --git a/src/reducers/subjects.js b/src/reducers/subjects.js new file mode 100644 index 0000000000..f868771626 --- /dev/null +++ b/src/reducers/subjects.js @@ -0,0 +1,14 @@ + +import {FETCH_SUBJECTS} from '../actions/index'; + +const INITIAL_STATE = []; + +export default function (state = INITIAL_STATE, action) { + + switch(action.type) { + case FETCH_SUBJECTS: + return action.payload.data; + default: + return state; + } +} diff --git a/webpack.config.js b/webpack.config.js new file mode 100644 index 0000000000..c4ba1cdb13 --- /dev/null +++ b/webpack.config.js @@ -0,0 +1,32 @@ +module.exports = { + entry: [ + './src/index.js' + ], + output: { + path: __dirname, + publicPath: '/', + filename: 'bundle.js' + }, + module: { + loaders: [{ + exclude: /node_modules/, + loader: 'babel-loader', + query: { + presets: ['react', 'es2015', 'stage-1'] + } + }] + }, + resolve: { + extensions: ['.js', '.jsx'] + }, + devServer: { + historyApiFallback: true, + contentBase: './', + headers: { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Credentials": "true", + "Access-Control-Allow-Headers": "Content-Type, Authorization, x-id, Content-Length, X-Requested-With", + "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS" + } + } +};