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"
+ }
+ }
+};