diff --git a/.babelrc b/.babelrc new file mode 100644 index 0000000..069b70c --- /dev/null +++ b/.babelrc @@ -0,0 +1,13 @@ +{ + "presets": ["env", "react"], + "plugins": ["transform-object-rest-spread"], + "env": { + "production": { + "plugins": [ + "transform-react-remove-prop-types", + "transform-react-constant-elements", + "transform-react-inline-elements" + ] + } + } +} diff --git a/.codeclimate.yml b/.codeclimate.yml new file mode 100644 index 0000000..a0c9896 --- /dev/null +++ b/.codeclimate.yml @@ -0,0 +1,14 @@ +engines: + duplication: + enabled: false + config: + languages: + javascript: + mass_threshold: 20 +ratings: + paths: + - "**.js" +exclude_paths: +- "test/" +- "node_modules/" +- "templates/" diff --git a/.coveralls.yml b/.coveralls.yml new file mode 100644 index 0000000..6d10cd2 --- /dev/null +++ b/.coveralls.yml @@ -0,0 +1 @@ +repo_token: COVERALLS_REPO_TOKEN \ No newline at end of file diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 0000000..90f6d7e --- /dev/null +++ b/.eslintignore @@ -0,0 +1,2 @@ +/server/test +/dist diff --git a/.eslintrc b/.eslintrc new file mode 100644 index 0000000..fd56ef6 --- /dev/null +++ b/.eslintrc @@ -0,0 +1,51 @@ +{ + "root": true, + "extends": ["airbnb-base", "plugin:react/recommended"], + "env": { + "browser": true, + "node": true, + "es6": true, + "mocha": true + }, + "parserOptions": { + "ecmaVersion": 6, + "ecmaFeatures": { "jsx": true } + }, + "settings": { + "import/resolver": { + "node": { + "extensions": [".js",".jsx"] + } + } + }, + "rules": { + "one-var": 0, + "one-var-declaration-per-line": 0, + "new-cap": 0, + "consistent-return": 0, + "no-param-reassign": 0, + "comma-dangle": 0, + "max-len": [1, 80, 2], + "import/prefer-default-export": 0, + "curly": ["error", "multi-line"], + "import/no-unresolved": [2, { "commonjs": true }], + "import/extensions": "off", + "no-shadow": ["error", { "allow": ["req", "res", "err"] }], + "react/jsx-uses-react": "error", + "react/jsx-uses-vars": "error", + "valid-jsdoc": ["error", { + "requireReturn": true, + "requireReturnType": true, + "requireParamDescription": false, + "requireReturnDescription": true + }], + "class-methods-use-this": 0, + "require-jsdoc": ["error", { + "require": { + "FunctionDeclaration": true, + "MethodDefinition": true, + "ClassDeclaration": true + } + }] + } +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bd106d0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,27 @@ +# See https://help.github.com/ignore-files/ for more about ignoring files. + +# dependencies +/node_modules + +# testing +/coverage +/.nyc_output + +# production +/build +/dist + +# misc +.DS_Store +/config/db.js +.env +.env.local +.env.development.local +.env.test.local +.env.production.local +/job +*.txt + +npm-debug.log* +yarn-debug.log* +yarn-error.log* diff --git a/.hound.yml b/.hound.yml new file mode 100644 index 0000000..6e05e36 --- /dev/null +++ b/.hound.yml @@ -0,0 +1,5 @@ +eslint: + enabled: true + config_file: .eslintrc + ignore_file: .eslintignore + \ No newline at end of file diff --git a/.sequelizerc b/.sequelizerc new file mode 100644 index 0000000..352fb29 --- /dev/null +++ b/.sequelizerc @@ -0,0 +1,8 @@ +const path = require('path'); + +module.exports = { + "config": path.resolve('./server/config', 'config.js'), + "models-path": path.resolve('./server/models'), + "seeders-path": path.resolve('./server/seeders'), + "migrations-path": path.resolve('./server/migrations') +}; diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..4700364 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,10 @@ +language: node_js +node_js: + - "6" +env: + global: + - export NODE_ENV=test +script: + - npm test +after_success: + - npm run coverage diff --git a/Procfile b/Procfile new file mode 100644 index 0000000..1ac9a30 --- /dev/null +++ b/Procfile @@ -0,0 +1 @@ +web: node dist/server/bin/www.js diff --git a/README.md b/README.md new file mode 100644 index 0000000..63a5baf --- /dev/null +++ b/README.md @@ -0,0 +1,63 @@ +[![Build Status](https://travis-ci.org/segunolalive/helloBooks.svg?branch=master)](https://travis-ci.org/segunolalive/helloBooks) +[![Coverage Status](https://coveralls.io/repos/github/segunolalive/helloBooks/badge.svg?branch=master)](https://coveralls.io/github/segunolalive/helloBooks?branch=master) +[![Code Climate](https://codeclimate.com/github/segunolalive/helloBooks/badges/gpa.svg)](https://codeclimate.com/github/segunolalive/helloBooks?branch=master) + +# helloBooks + +### A Library app +Hello books is an application that provides users with access to books from wherever they are. +Beeing a virtual library, users can borrow and read their favorite books using any device. +HelloBooks exposes RESTful API endpoints such that anyone customize the method of consuming +the resources. + +### Development +This is a javascript application built with [**Express**](http://expressjs.com/) +framework on the nodejs platform. Authentication of users is done via +[**JSON Web Tokens**](https://jwt.io/) . + +#### Features +- Login/Sign up to gain access to routes +- A library of books from different categories +- Ability to borrow books repeatedly +- Track your reading/borrowing history +- Admin access to modify book details + +#### API Routes +- sign up route: +**POST** /api/v1/users/signup +parameters - username, password, email +optional parameters - firstName, lastName + + +- login route: +**POST** /api/v1/users/signin +parameters - username, password + +- get books (view library): +**GET** /api/v1/books' + +- get book (view a book's metadata): +**GET** /api/v1/books/:id +parameters - bookId (number) + +- add a new book to library: +**POST** /api/v1/books +request body - authors, title, description, cover, bookfile, total + +- modify book information: +**PUT** /api/v1/books/ +request body - authors, title, description, cover, bookfile, total (any) +query parameters - book id (number) + +- borrow book: +**POST** /api/v1/users/:id/books +parameters - user id +query parameters - book id (number) + +- return book: +**PUT** /api/v1/users/:id/books +parameters - user id +query parameters - book id (number) + +- get borrowed books: +**GET** /api/v1/users/:id/books diff --git a/app.json b/app.json new file mode 100644 index 0000000..11b38ec --- /dev/null +++ b/app.json @@ -0,0 +1,27 @@ +{ + "name": "helloBooks", + "scripts": {}, + "env": { + "DATABASE_URL": { + "required": true + }, + "NODE_ENV": { + "required": true + }, + "NODE_MODULES_CACHE": { + "required": true + }, + "SECRET": { + "required": true + } + }, + "formation": {}, + "addons": [ + "papertrail" + ], + "buildpacks": [ + { + "url": "heroku/nodejs" + } + ] +} diff --git a/client/actions/actionTypes.js b/client/actions/actionTypes.js new file mode 100644 index 0000000..221dd7d --- /dev/null +++ b/client/actions/actionTypes.js @@ -0,0 +1,38 @@ +import keyMirror from './keyMirror'; +/** + * array of action types + * @type {Array} + */ +const actionList = [ + 'SIGN_UP', + 'LOGIN', + 'LOGOUT', + 'SET_LOGIN_STATUS', + 'UPDATE_PROFILE', + 'SET_BOOK_ID', + 'GET_BOOK', + 'GET_BOOKS', + 'GET_BORROWED_BOOKS', + 'GET_TRANSACTION_HISTORY', + 'GET_ALL_BORROWED', + 'GET_BOOK_CATEGORIES', + 'BORROW_BOOK', + 'RETURN_BOOK', + 'CREATE_BOOK', + 'READ_BOOK', + 'EDIT_BOOK_INFO', + 'DELETE_BOOK', + 'UPLOAD_BOOK_FILE', + 'UPLOAD_BOOK_COVER', + 'SAVE_STATE', + 'GET_SAVED_STATE', + 'GET_ADMIN_NOTIFICATIONS', +]; + +/** + * action types object + * @type {Object} + */ +const actionTypes = keyMirror(actionList); + +export default actionTypes; diff --git a/client/actions/adminActions.js b/client/actions/adminActions.js new file mode 100644 index 0000000..e745147 --- /dev/null +++ b/client/actions/adminActions.js @@ -0,0 +1,106 @@ +import axios from 'axios'; +import actionTypes from '../actions/actionTypes'; +import API from './api'; + +const Materialize = window.Materialize; + + +/** + * @param {Object} book - book + * @returns {Object} - Object containing action type and user + */ +export const editBookAction = book => ({ + type: actionTypes.EDIT_BOOK, + book, +}); + + +/** + * edit book Detail + * @param {Integer} id book Id + * @param {Object} data book data with with to update database + * @return {Object} dispatches an action to the redux store + */ +export const editBook = (id, data) => dispatch => ( + axios.put(`${API}/books/${id}`, data) + .then((response) => { + Materialize.toast(response.data.message, 2500, 'teal darken-4'); + }, (error) => { + Materialize.toast(error.response.data.message, 2500, 'red darken-4'); + }) + .catch((error) => { + Materialize.toast(error, 2500, 'red darken-4'); + }) +); + + +/** + * @param {Object} book - book + * @returns {Object} - Object containing action type and book + */ +export const addBookAction = book => ({ + type: actionTypes.ADD_BOOK, + book, +}); + + +/** + * @param {Object} book - book + * @returns {Object} - Object containing action type and book + */ +export const setBookToEdit = book => ({ + type: actionTypes.SET_BOOK_TO_EDIT, + book, +}); + + +/** + * get the book to edit + * @param {Number} id book id + * @return {Object} redux action + */ +export const bookToEdit = id => dispatch => ( + axios.get(`${API}/books/${id}`) + .then((response) => { + dispatch(setBookToEdit(response.data.data)); + }) + .catch((error) => { + Materialize.toast(error, 2500, 'red darken-4'); + }) +); + + +/** + * add new book to database + * @param {Object} data book data + * @return {Object} dispatches an action to the redux store + */ +export const addBook = data => dispatch => ( + axios.post(`${API}/books`, data) + .then((response) => { + Materialize.toast(response.data.message, 2500, 'teal darken-4'); + }, (error) => { + Materialize.toast(error.response.data.message, 2500, 'red darken-4'); + }) + .catch((error) => { + Materialize.toast(error, 2500, 'red darken-4'); + }) +); + + +/** + * het book Detail + * @param {Object} category new book category + * @return {Object} dispatches an action to the redux store + */ +export const addBookCategory = category => dispatch => ( + axios.post(`${API}/books/category`, { category }) + .then((response) => { + Materialize.toast(response.data.message, 2500, 'teal darken-4'); + }) + .catch(() => { + Materialize.toast(`Something went wrong. Ensure you're not adding + an existing category`, 2500, 'red darken-4' + ); + }) +); diff --git a/client/actions/api.js b/client/actions/api.js new file mode 100644 index 0000000..173d4eb --- /dev/null +++ b/client/actions/api.js @@ -0,0 +1,13 @@ +let api = '/api/v1'; + +if (process.env.NODE_ENV === 'development') { + api = 'http://localhost:5000/api/v1'; +} + +/** + * api url + * @type {String} + */ +const API = api; + +export default API; diff --git a/client/actions/borrowedBooks.js b/client/actions/borrowedBooks.js new file mode 100644 index 0000000..9b20472 --- /dev/null +++ b/client/actions/borrowedBooks.js @@ -0,0 +1,70 @@ +import axios from 'axios'; +import actionTypes from '../actions/actionTypes'; +import API from './api'; + +const Materialize = window.Materialize; + + +/** + * @param {Array} borrowedBooks - array of books borrowed by user + * @returns {Object} - action object + */ +export const getBorrowedBooksAction = borrowedBooks => ({ + type: actionTypes.GET_BORROWED_BOOKS, + borrowedBooks, +}); + + +/** +* @param {object} id - user id +* @returns {any} - dispatches action with books user has not returned +*/ +export const fetchBorrowedBooks = id => (dispatch) => { + axios.get(`${API}/users/${id}/books?returned=false`) + .then((response) => { + dispatch(getBorrowedBooksAction(response.data.data)); + }); +}; + + +/** +* @param {object} id - user id +* @returns {any} - dispatches action with all books ever borrowed by user +*/ +export const fetchBorrowingHistory = id => (dispatch) => { + axios.get(`${API}/users/${id}/books`) + .then((response) => { + dispatch(getBorrowedBooksAction(response.data.data)); + }); +}; + +/** + * @param {integer} id book id + * @return {Object} action object + */ +const returnBookAction = id => ({ + type: actionTypes.RETURN_BOOK, + id, +}); + + +/** +* @param {object} userId - user id +* @param {object} bookId - book id +* @returns {any} - fetches array of unreturned borrowed books +*/ +export const returnBook = (userId, bookId) => dispatch => ( + axios.put(`${API}/users/${userId}/books`, { id: bookId }) + .then( + (response) => { + dispatch(returnBookAction(bookId)); + Materialize.toast(response.data.message, 2500, 'teal darken-4'); + }, + (error) => { + Materialize.toast(error.response.data.message, 2500, 'red darken-4'); + } + ) + .catch((err) => { + Materialize.toast(err.response.data.message, 2500, 'red darken-4'); + }) +); diff --git a/client/actions/history.js b/client/actions/history.js new file mode 100644 index 0000000..e388a18 --- /dev/null +++ b/client/actions/history.js @@ -0,0 +1,43 @@ +import axios from 'axios'; + +import actionTypes from '../actions/actionTypes'; +import API from './api'; + +/** + * Action creator for getting list of all books a userr has ever borrowed + * @param {Array} books array of books + * @return {Object} redux action with type and books properties + */ +const fetchHistoryAction = books => ({ + type: actionTypes.GET_ALL_BORROWED, + books +}); + +/** + * fetch list of all books a user has ever borrowed + * @param {Integer} id user id + * @return {Thunk} a function that retuns a function (action creator) + */ +export const fetchHistory = id => dispatch => ( + axios.get(`${API}/users/${id}/books`) + .then(response => ( + dispatch(fetchHistoryAction(response.data.data)) + )) +); + +/** + * Action creator for getting full transactions history + * @param {array} transactions array of transacitons + * @return {Thunk} function that returns an action creator + */ +const getTransactionHistory = transactions => ({ + type: actionTypes.GET_TRANSACTION_HISTORY, + transactions, +}); + +export const fetchTransactionHistory = id => dispatch => ( + axios.get(`${API}/users/${id}/transactions`) + .then(response => ( + dispatch(getTransactionHistory(response.data.notifications)) + )) +); diff --git a/client/actions/keyMirror.js b/client/actions/keyMirror.js new file mode 100644 index 0000000..9ced199 --- /dev/null +++ b/client/actions/keyMirror.js @@ -0,0 +1,17 @@ +/** + * converts an array to Object with array items as keys and values + * @example ['books'] becomes { books: 'books' } + * @param {Array} keys array of keys + * @return {Object} Object with keys and values mirrored + */ +const keyMirror = (keys) => { + keys = Array.isArray(keys) ? keys : Object.keys(keys); + const mirror = {}; + keys.forEach((key) => { + mirror[key] = key; + return mirror; + }); + return mirror; +}; + +export default keyMirror; diff --git a/client/actions/library.js b/client/actions/library.js new file mode 100644 index 0000000..303ffa5 --- /dev/null +++ b/client/actions/library.js @@ -0,0 +1,142 @@ +import axios from 'axios'; + +import actionTypes from '../actions/actionTypes'; +import API from './api'; + +const Materialize = window.Materialize; + + +/** + * action creator for getting books + * @param {Array} books array of book objects + * @return {Object} action objects + */ +export const getBooks = books => ({ + type: actionTypes.GET_BOOKS, + books, +}); + + +/** + * fetch books in thhe Library + * @return {any} dispatches an action + */ +export const fetchBooks = () => dispatch => ( + axios.get(`${API}/books`) + .then((response) => { + dispatch(getBooks(response.data.data)); + }, (error) => { + Materialize.toast(error.response.data.message, 2500, 'red darken-4'); + }) + .catch((error) => { + Materialize.toast(error.response.data.message, 2500, 'red darken-4'); + }) +); + +/** + * action creator for borrowing books + * @param {Integer} id book id + * @return {Object} action object + */ +const borrowBookAction = id => ({ + type: actionTypes.BORROW_BOOK, + id, +}); + + +/** + * send request to borrow a book from library + * @param {integer} userId user id + * @param {integer} bookId book id + * @return {any} dispatches an action to store + */ +export const borrowBook = (userId, bookId) => dispatch => ( + axios.post(`${API}/users/${userId}/books`, { id: bookId }) + .then((response) => { + dispatch(borrowBookAction(bookId)); + Materialize.toast(response.data.message, 2500, 'teal darken-4'); + }, (error) => { + Materialize.toast(error.response.data.message, 2500, 'red darken-4'); + }) + .catch((error) => { + Materialize.toast(error, 2500, 'red darken-4'); + }) +); + + +/** + * action creator for borrowing books + * @param {Integer} id book id + * @return {Object} action object + */ +const editBookAction = id => ({ + type: actionTypes.EDIT_BOOK, + id, +}); + + +/** + * send request to borrow a book from library + * @param {integer} bookId book id + * @return {any} dispatches an action to store + */ +export const editBook = bookId => dispatch => ( + axios.put(`${API}/books/${bookId}`, { id: bookId }) + .then((response) => { + dispatch(editBookAction(bookId)); + Materialize.toast(response.data.message, 2500, 'teal darken-4'); + }, (error) => { + Materialize.toast(error.response.data.message, 2500, 'red darken-4'); + }) + .catch((error) => { + Materialize.toast(error, 2500, 'red darken-4'); + }) +); + + +/** + * action creator for borrowing books + * @param {Integer} id book id + * @return {Object} action object + */ +const deleteBookAction = id => ({ + type: actionTypes.DELETE_BOOK, + id, +}); + + +/** + * send request to borrow a book from library + * @param {integer} bookId book id + * @return {any} dispatches an action to store + */ +export const deleteBook = bookId => dispatch => ( + axios.delete(`${API}/books/${bookId}`, { id: bookId }) + .then((response) => { + dispatch(deleteBookAction(bookId)); + return response; + }) +); + +/** + * @param {Array} categories book categories + * @return {Object} dispatches an action to store + */ +const getBookCategoriesAction = categories => ({ + type: actionTypes.GET_BOOK_CATEGORIES, + categories, +}); + +/** + * get book cattegories + * @return {any} dispatches an action to the redux store + */ +export const getBookCategories = () => (dispatch) => { + axios.get(`${API}/books/category`) + .then(categories => ( + dispatch(getBookCategoriesAction(categories.data.data)) + )); +}; + + +export const filterBooksByCategory = category => (dispatch) => {}; diff --git a/client/actions/login.js b/client/actions/login.js new file mode 100644 index 0000000..0155a40 --- /dev/null +++ b/client/actions/login.js @@ -0,0 +1,41 @@ +import axios from 'axios'; +import actionTypes from '../actions/actionTypes'; +import setAuthorizationToken from '../utils/setAuthorizationToken'; +import API from './api'; + + +/** + * @param {Object} user - user data + * @returns {Object} - Object containing action type and user + */ +export const loginUser = user => ({ + type: actionTypes.LOGIN, + user, +}); + + +/** + * @param {Boolean} status - status + * @returns {Object} - Object containing action type and login status + */ +export const setLoginStatus = status => ({ + type: actionTypes.SET_LOGIN_STATUS, + isLoggedIn: status, +}); + + +/** + * @param {object} data - user data + * @returns {any} - dispatches login user action + */ +export const login = data => dispatch => ( + axios.post(`${API}/users/signin`, data) + .then((response) => { + const token = response.data.token; + localStorage.setItem('token', token); + setAuthorizationToken(token); + dispatch(loginUser(response.data)); + dispatch(setLoginStatus(true)); + return response.data; + }) +); diff --git a/client/actions/logout.js b/client/actions/logout.js new file mode 100644 index 0000000..1735cc4 --- /dev/null +++ b/client/actions/logout.js @@ -0,0 +1,13 @@ +import actionTypes from '../actions/actionTypes'; + + +export const logoutUser = () => ({ type: actionTypes.LOGOUT }); + +/** + * @param {Function} dispatch + * @returns {Object} Object containing action type + */ +export const logout = () => (dispatch) => { + localStorage.removeItem('token'); + dispatch(logoutUser()); +}; diff --git a/client/actions/notifications.js b/client/actions/notifications.js new file mode 100644 index 0000000..0b21cab --- /dev/null +++ b/client/actions/notifications.js @@ -0,0 +1,21 @@ +import axios from 'axios'; + +import actionTypes from '../actions/actionTypes'; +import API from './api'; + +const adminNotifications = notifications => ({ + type: actionTypes.GET_ADMIN_NOTIFICATIONS, + notifications, +}); + +/** + * get admin notifications from server + * @return {Function} dispatches an action creator + */ +export const fetchNotifications = () => dispatch => ( + axios.get(`${API}/admin-notifications`) + .then(response => ( + dispatch(adminNotifications(response.data.notifications)) + )) + .catch(() => {}) +); diff --git a/client/actions/signup.js b/client/actions/signup.js new file mode 100644 index 0000000..3849c68 --- /dev/null +++ b/client/actions/signup.js @@ -0,0 +1,28 @@ +import axios from 'axios'; +import actionTypes from '../actions/actionTypes'; +import API from './api'; +import { setLoginStatus } from './login'; + +/** + * @param {any} user - user + * @returns {Object} - Object containing action type and user + */ +export const signUpUser = (user => ({ + type: actionTypes.SIGN_UP, + user, +})); + + +/** + * @param {any} data - user data + * @returns {any} - dispatches login user action + */ +export const signUp = data => dispatch => ( + axios.post(`${API}/users/signup`, data) + .then((response) => { + localStorage.setItem('token', response.data.token); + dispatch(signUpUser(response.data)); + dispatch(setLoginStatus(true)); + return response.data; + }) +); diff --git a/client/actions/viewBook.js b/client/actions/viewBook.js new file mode 100644 index 0000000..a6fe4f3 --- /dev/null +++ b/client/actions/viewBook.js @@ -0,0 +1,39 @@ +import axios from 'axios'; +import actionTypes from '../actions/actionTypes'; +import API from './api'; + +const Materialize = window.Materialize; + +/** + * @param {Object} book - book + * @returns {Object} - Object containing action type and book + */ +export const getBook = book => ({ + type: actionTypes.GET_BOOK, + book, +}); + + +/** + * @param {Integer} id - book id + * @returns {Object} - Object containing action type and book id + */ +export const setBookId = id => ({ + type: actionTypes.SET_BOOK_ID, + id, +}); + + +/** + * get book Detail + * @param {Integer} id book Id + * @return {any} dispatches an action to the redux store + */ +export const viewBookDetails = id => dispatch => ( + axios.get(`${API}/books/${id}`) + .then((response) => { + dispatch(getBook(response.data.data)); + }, (error) => { + Materialize.toast(error.response.data.message, 2500, 'red darken-4'); + }) +); diff --git a/client/components/404.jsx b/client/components/404.jsx new file mode 100644 index 0000000..e857c3b --- /dev/null +++ b/client/components/404.jsx @@ -0,0 +1,14 @@ +import React from 'react'; +import Header from './header/Header'; + +const notFound = () => ( +
+
+
+

404

+

Not Found

+
+
+); + +export default notFound; diff --git a/client/components/App.jsx b/client/components/App.jsx new file mode 100644 index 0000000..024d944 --- /dev/null +++ b/client/components/App.jsx @@ -0,0 +1,71 @@ +import React, { Component } from 'react'; +import { BrowserRouter, Route, Switch } from 'react-router-dom'; + + +import Home from './Home'; +import History from './history/History'; +import notFound from './404'; +import Admin from './admin/Admin'; +import Login from './auth/Login'; +import SignUp from './auth/SignUp'; +import Library from './library/Library'; +import BookDetail from './library/BookDetail'; +import Dashboard from './dashboard/Dashboard'; +import Logout from './auth/Logout'; +// import ForgotPassword from './forgotPassword'; +// +// import UpdateProfile from './dashboard/UpdateProfile'; + +import mock from './mock'; + + +/** + * @public + * @class App + * @description React Component encapsulating application user interface + * @extends {Component} + */ +class App extends Component { + /** + * Creates an instance of App. + * @param {Object} props + * @memberof App + */ + constructor(props) { + super(props); + this.categories = mock.categories; + this.books = mock.books; + } + /** + * renders app to DOM + * + * @returns {JSX} JSX representation of component + * @memberof App + */ + render() { + return ( + +
+ + + + {/* */} + + + + + + + + + + {/* */} + + +
+
+ ); + } +} + +export default App; diff --git a/client/components/Home.jsx b/client/components/Home.jsx new file mode 100644 index 0000000..fceda0c --- /dev/null +++ b/client/components/Home.jsx @@ -0,0 +1,46 @@ +import React from 'react'; +import { Link, Redirect } from 'react-router-dom'; +import PropTypes from 'prop-types'; +import { Row } from 'react-materialize'; +import { connect } from 'react-redux'; +import Header from './header/Header'; + + +/** + * Landing page + * @param {Object} props Object containing isLoggedIn key + * @returns {JSX} JSX representation of the the landing page + */ +const Home = props => ( + props.isLoggedIn ? : +
+
+
+ +
+
+
+

Welcome To Hello Books

+
+
+ login + signup +
+
+
+
+
+
+); + +Home.propTypes = { + isLoggedIn: PropTypes.bool, +}; + +const mapStateToProps = ({ authReducer }) => ({ + isLoggedIn: authReducer.isLoggedIn, +}); + +export default connect(mapStateToProps)(Home); diff --git a/client/components/Loading.jsx b/client/components/Loading.jsx new file mode 100644 index 0000000..88a92db --- /dev/null +++ b/client/components/Loading.jsx @@ -0,0 +1,25 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Col, ProgressBar, Row } from 'react-materialize'; + +/** + * displays a loading progress bar + * @param {Object} props object with property, text + * @returns {JSX} JSX element + */ +const Loading = props => ( +
+ + + {props.text} + + + +
+); + +Loading.propTypes = { + text: PropTypes.string, +}; + +export default Loading; diff --git a/client/components/admin/Admin.jsx b/client/components/admin/Admin.jsx new file mode 100644 index 0000000..8d2ef86 --- /dev/null +++ b/client/components/admin/Admin.jsx @@ -0,0 +1,204 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { connect } from 'react-redux'; +import { Redirect } from 'react-router-dom'; +import { Col, Row } from 'react-materialize'; + +import { addBook, addBookCategory, editBook } from '../../actions/adminActions'; +import { fetchNotifications } from '../../actions/notifications'; +import { getBookCategories } from '../../actions/library'; + +import Header from '../header/Header'; +import BookForm from './BookForm'; +import Notifications from './Notifications'; +import AddCategoryForm from './CategoryForm'; + +/** + * adds or edits book + * + * @class AddBook + * @extends {Component} + */ +class Admin extends Component { + /** + * Creates an instance of AddBook. + * @param {any} props + * @memberof AddBook + */ + constructor(props) { + super(props); + this.shouldEdit = this.props.location.pathname.match(/^\/admin\/edit/); + this.state = this.shouldEdit ? { + title: this.props.book.title || '', + authors: this.props.book.authors || '', + description: this.props.book.description || '', + category: this.props.book.category || '', + total: this.props.book.total || '', + cover: this.props.book.cover || '', + bookFile: this.props.book.bookFile || '', + } : { + title: '', + authors: '', + description: '', + category: '', + total: '', + cover: '', + bookFile: '', + }; + this.handleFormSubmission = this.handleFormSubmission.bind(this); + this.handleFieldChange = this.handleFieldChange.bind(this); + this.handleSelectCategory = this.handleSelectCategory.bind(this); + this.handleAddCategory = this.handleAddCategory.bind(this); + } + + /** + * lifecycle methods called after component mounts the DOM + * @memberof AddBook + * @returns {Undefined} fetches book categories and admin notifications + */ + componentDidMount() { + this.props.getBookCategories(); + this.props.fetchNotifications(); + } + + /** + * form submission handler + * + * @param {any} event + * @memberof AddBook + * @returns {Undefined} submits form + */ + handleFormSubmission(event) { + event.preventDefault(); + this.shouldEdit ? + this.props.editBook(this.props.book.id, this.state) + .then(() => this.props.history.push('/library')) : + this.props.addBook(this.state); + this.setState(() => ({ + title: '', + description: '', + category: '', + total: '', + cover: '', + bookFile: '', + })); + } + + /** + * updates component state when form values (except select field) change + * + * @param {any} event + * @memberof AddBook + * @returns {Undefined} calls setState + */ + handleFieldChange(event) { + event.preventDefault(); + const formField = event.target.name; + const data = Object.assign({}, this.state); + if (event.target.value.trim()) { + data[formField] = event.target.value.trim(); + } + this.setState(() => data); + } + + /** + * handles selection of book category + * + * @param {any} event + * @memberof AddBook + * @returns {Undefined} calls setState + */ + handleSelectCategory(event) { + this.setState(() => ({ category: event.target.value })); + } + + /** + * handles adding a new category + * + * @param {any} event + * @memberof AddBook + * @returns {Undefined} updatea list of categories + */ + handleAddCategory(event) { + event.preventDefault(); + const category = event.target.category.value.trim(); + if (category) { + this.props.addBookCategory(category); + event.target.category.value = ''; + } + this.props.getBookCategories(); + } + + /** + * renders component to DOM + * + * @returns {JSX} JSX representation of component + * @memberof AddBook + */ + render() { + const text = this.shouldEdit ? + 'Edit Book Information' : + 'Add Book To Library'; + return (this.props.user && this.props.user.isAdmin ? +
+
+
+ +
+ + +
+ +
+
+ +
+ +
+
Notifications
+ +
+
+
+
+
+
: + ); + } +} + +Admin.propTypes = { + user: PropTypes.object.isRequired, + book: PropTypes.object, + categories: PropTypes.array.isRequired, + notifications: PropTypes.array.isRequired, + addBook: PropTypes.func.isRequired, + editBook: PropTypes.func.isRequired, + getBookCategories: PropTypes.func.isRequired, + fetchNotifications: PropTypes.func.isRequired, + addBookCategory: PropTypes.func.isRequired, + history: PropTypes.object.isRequired, + location: PropTypes.object.isRequired, +}; + + +const mapStateToProps = ({ authReducer, bookReducer, notifications }) => ({ + user: authReducer.user, + book: bookReducer.currentBook, + categories: bookReducer.categories, + notifications, +}); + +export default connect( + mapStateToProps, + { addBook, addBookCategory, editBook, getBookCategories, fetchNotifications } +)(Admin); diff --git a/client/components/admin/BookForm.jsx b/client/components/admin/BookForm.jsx new file mode 100644 index 0000000..7baa5ae --- /dev/null +++ b/client/components/admin/BookForm.jsx @@ -0,0 +1,124 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +import Categories from '../library/Categories'; + +/** + * for for adding or editing books + * @param {object} props + * @returns {JSX} JSX representation of component + */ +const BookForm = (props) => { + const categories = props.categories ? + : null; + return ( +
+
{props.heading}
+
+
+ props.onChange(event)} + /> +
+
+ props.onChange(event)} + /> +
+
+