diff --git a/.babelrc b/.babelrc deleted file mode 100644 index 20c8880..0000000 --- a/.babelrc +++ /dev/null @@ -1,16 +0,0 @@ -{ - "presets": [ - ["latest", {"modules": false}], - "react" - ], - - "plugins": ["transform-object-rest-spread"], - - "env": { - "development": { - "presets": ["react-hmre"] - } - } - - -} diff --git a/.editorconfig b/.editorconfig deleted file mode 100644 index 60ed390..0000000 --- a/.editorconfig +++ /dev/null @@ -1,31 +0,0 @@ -# Go to editorconfig.org to install the plugin for your appropriate editor. - - -# top-most EditorConfig file -root = true - -# Unix-style newlines with a newline ending every file -[*] -end_of_line = lf -insert_final_newline = true -trim_training_whitespace = true - -# Matches multiple files with brace expansion notation -# Set default charset -[*.{js,java}] -charset = utf-8 - -# 4 space indentation -[*.{js,java}] -indent_style = space -indent_size = 4 - -# Indentation override for all JS under lib directory -[lib/**.js] -indent_style = space -indent_size = 4 - -# Matches the exact files either package.json or .travis.yml -[{package.json}] -indent_style = space -indent_size = 4 diff --git a/.eslintrc.yaml b/.eslintrc.yaml deleted file mode 100644 index 50fc55a..0000000 --- a/.eslintrc.yaml +++ /dev/null @@ -1,72 +0,0 @@ -root: true - -extends: - - eslint:recommended - - plugin:import/errors - - plugin:import/warnings - -plugins: [ react, jsx-a11y ] - - -parserOptions: - ecmaVersion: 7 - sourceType: module - ecmaFeatures: - jsx: true - -env: - es6: true - browser: true - node: true - mocha: true - -parser: 'babel-eslint' - -rules: - quotes: 0 - no-console: 1 - no-debugger: 1 - no-var: 1 - semi: [1, "always"] - no-trailing-spaces: 0 - eol-last: 0 - no-unused-vars: 1 - no-underscore-dangle: 0 - no-alert: 0 - no-lone-blocks: 0 - jsx-quotes: 1 - react/display-name: [ 1, ignoreTranspilerName: false ] - react/forbid-prop-types: [1, forbid: [any]] - react/jsx-boolean-value: 1 - react/jsx-closing-bracket-location: 0 - react/jsx-curly-spacing: 1 - react/jsx-indent-props: 0 - react/jsx-key: 1 - react/jsx-max-props-per-line: 0 - react/jsx-no-bind: 1 - react/jsx-no-duplicate-props: 1 - react/jsx-no-literals: 0 - react/jsx-no-undef: 1 - react/jsx-pascal-case: 1 - react/jsx-sort-prop-types: 0 - react/jsx-sort-props: 0 - react/jsx-uses-react: 1 - react/jsx-uses-vars: 1 - jsx-a11y/img-has-alt: 1 - react/no-danger: 1 - react/no-did-mount-set-state: 1 - react/no-did-update-set-state: 1 - react/no-direct-mutation-state: 1 - react/no-multi-comp: 1 - react/no-set-state: 0 - react/no-unknown-property: 1 - react/prefer-es6-class: 1 - react/prop-types: 1 - react/react-in-jsx-scope: 1 - import/extensions: 1 - react/self-closing-comp: 1 - react/sort-comp: 1 - react/jsx-wrap-multilines: 1 - eqeqeq: 1 - array-callback-return: 1 - no-unused-expressions: 1 \ No newline at end of file diff --git a/.gitignore b/.gitignore index e76eabf..15f5d05 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,19 @@ -# Logs -npm-debug.log* +# See https://help.github.com/ignore-files/ for more about ignoring files. + +# dependencies +/node_modules + +# testing +/coverage -# Dependency directories -node_modules -src/api/db.json +# production +/build + +# misc +.DS_Store +.env +npm-debug.log* +yarn-debug.log* +yarn-error.log* -dist/ +coverage/ \ No newline at end of file diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..d227956 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,15 @@ +language: node_js + +node_js: + - "7" + +cache: + yarn: true + directories: + - node_modules + +script: + - yarn coverage + +after_script: "cat ./coverage/lcov.info | coveralls" + diff --git a/README.md b/README.md index 6787dd7..487875f 100644 --- a/README.md +++ b/README.md @@ -1,47 +1,80 @@ -# ReactJs CRUD Boilerplate +# ReactJs CRUD Boilerplate +[![Build Status](https://travis-ci.org/ariesmcrae/reactjs-crud-boilerplate.svg?branch=master)](https://travis-ci.org/ariesmcrae/reactjs-crud-boilerplate) [![Coverage Status](https://coveralls.io/repos/github/ariesmcrae/reactjs-crud-boilerplate/badge.svg)](https://coveralls.io/github/ariesmcrae/reactjs-crud-boilerplate) -* aka starter kit -* aka Starter project -* aka Seed +[![dependencies Status](https://david-dm.org/ariesmcrae/reactjs-crud-boilerplate/status.svg)](https://david-dm.org/ariesmcrae/reactjs-crud-boilerplate) [![devDependencies Status](https://david-dm.org/ariesmcrae/reactjs-crud-boilerplate/dev-status.svg)](https://david-dm.org/ariesmcrae/reactjs-crud-boilerplate?type=dev) -### Libraries used +## Demo (Live Interactive) +[https://d3cmu8mv5wwijw.cloudfront.net](https://d3cmu8mv5wwijw.cloudfront.net) + +## Preview +Preview + +## Prerequisite +* Nodejs v6+ +* yarnpkg (optional) + + +## Getting Started +```sh +git clone https://github.com/ariesmcrae/reactjs-crud-boilerplate.git + +cd reactjs-crud-boilerplate + +yarn install + or +npm install + +yarn start + or +npm start +``` + +Open [http://localhost:3000](http://localhost:3000)
+ + +## Libraries used * ReactJs -* Yarn -* Express server -* npm scripts -* Node Security +* Redux +* create-react-app +* React Router 4 +* Bootstrap 4 +* redux-form +* React Boostrap Table * Babel -* Webpack 1.x -* ESLint, eslint-watch -* Test Framework: Mocha -* Test Assertion: Chai -* Test Helper Library: Enzyme, JSDOM -* Test Headless DOM: JSDOM - in memory DOM where tests are executed. +* ESLint +* Test Runner: Jest +* Test Assertion: Jest +* Test Helper Library: Enzyme +* Test Headless DOM: JSDOM * react-addons-test-utils: needed by Enzyme -* WhatWG Fetch * Mock API Data: hand rolled -* `webpack-hot-middleware` - hot module replacement when using `ExpressJs` as a server, rather than using `webpack-dev-server`. -* `eslint-plugin-react` -* JSDOM -* react-router 4 -* toastr (unfortunately, I had to install jquery with it, as it wouldn't work without it) +* toastr +* jquery (needed by toastr and bootstrap 4) * lodash -* Bootstrap 4 -* Fontawesome: because Bootstrap 4 no longer suppies glyphicons -* Tether is required by Bootstrap 4 -* jquery is required by Bootstrap 4 +* Font Awesome: because Bootstrap 4 no longer suppies glyphicons +* Tether +* jquery * thunk testing: nock (for mocking http calls), and redux-mock-store +* code coverage: Jest & coveralls + + +## Non create-react-app version +It's located in the branch **non-create-react-app**. +It uses hand crafted `Webpack 2` + +```sh +git clone https://github.com/ariesmcrae/reactjs-crud-boilerplate.git -### Webpack plugins -* Dynamic HTML generation: html-webpack-plugin -* CSS minification: extract-text-webpack-plugin -* Bundle splitting. +cd reactjs-crud-boilerplate +git checkout -b non-create-react-app origin/non-create-react-app +yarn install +yarn start +``` -### Credits +## Credits This project took inspirations from : -* `javascript-development-environment` by @coryhouse -* `react-redux-react-router-es6` by @coryhouse -* `bootstrap-4-playlist` by @iamshaunjp +* [react-redux-react-router-es6](https://github.com/coryhouse/pluralsight-redux-starter) by [@coryhouse](https://twitter.com/housecor) +* [bootstrap-4-playlist](https://github.com/iamshaunjp/bootstrap-4-playlist) by [@iamshaunjp](https://github.com/iamshaunjp) diff --git a/package.json b/package.json index c45dc97..a71bd45 100644 --- a/package.json +++ b/package.json @@ -1,9 +1,9 @@ { - "name": "reactjs-crud-starterkit", - "description": "ReactJS CRUD Starter Kit", + "name": "reactjs-crud-boilerplate", + "description": "ReactJS CRUD Boilerplate", "version": "0.0.2", - "main": "index.js", - "repository": "https://github.com/ariesmcrae/reactjs-crud-starter-kit.git", + "private": false, + "repository": "https://github.com/ariesmcrae/reactjs-crud-boilerplate.git", "author": "Aries McRae ", "license": "MIT", "keywords": [ @@ -12,73 +12,36 @@ "Starter Project", "Seed" ], - "scripts": { - "prestart": "babel-node scripts/startMessage.js", - "start": "npm-run-all --parallel security-check lint:watch test:watch startServerDev", - "startServerDev": "babel-node ./scripts/serverDev.js", - "security-check": "nsp check", - "lint": "esw webpack.config.* src scripts --color", - "lint:watch": "yarn lint -- --watch", - "test": "mocha --reporter spec --compilers scripts/testSetup.js --recursive test", - "test:watch": "yarn test -- --watch", - "clean-dist": "node_modules/.bin/rimraf dist && mkdir dist", - "prebuild": "npm-run-all clean-dist test lint", - "build": "babel-node scripts/build.js", - "postbuild": "babel-node scripts/serverProd.js" - }, "dependencies": { "bootstrap": "4.0.0-alpha.6", "font-awesome": "^4.7.0", - "jquery": "^3.2.0", + "jquery": "^3.2.1", "lodash": "^4.17.4", "react": "^15.4.2", + "react-bootstrap-table": "^3.4.1", "react-dom": "^15.4.2", - "react-redux": "^5.0.2", - "react-router-dom": "^4.0.0", - "redux": "^3.6.0", - "redux-form": "^6.5.0", + "react-redux": "^5.0.5", + "react-router-dom": "^4.1.1", + "redux": "^3.7.0", + "redux-form": "^6.8.0", "redux-thunk": "^2.2.0", "tether": "^1.4.0", "toastr": "^2.1.2" }, "devDependencies": { - "babel-cli": "^6.24.0", - "babel-core": "^6.24.0", - "babel-eslint": "^7.1.1", - "babel-loader": "^6.4.1", - "babel-plugin-transform-object-rest-spread": "^6.23.0", - "babel-preset-latest": "^6.24.0", - "babel-preset-react": "^6.23.0", - "babel-preset-react-hmre": "^1.1.1", - "babel-register": "^6.24.0", - "chai": "^3.5.0", - "chalk": "^1.1.3", - "compression": "^1.6.2", - "css-loader": "^0.27.3", - "enzyme": "^2.7.1", - "eslint": "^3.15.0", - "eslint-plugin-import": "^2.2.0", - "eslint-plugin-jsx-a11y": "^4.0.0", - "eslint-plugin-react": "^6.9.0", - "eslint-watch": "^3.0.0", - "express": "^4.14.1", - "extract-text-webpack-plugin": "^2.0.0", - "file-loader": "^0.10.0", - "html-webpack-plugin": "^2.28.0", - "jsdom": "^9.12.0", - "mocha": "^3.2.0", - "nock": "^9.0.9", - "npm-run-all": "^4.0.1", - "nsp": "^2.6.2", - "open": "^0.0.5", + "coveralls": "^2.13.1", + "enzyme": "^2.8.2", + "enzyme-to-json": "^1.5.1", + "nock": "^9.0.13", "react-addons-test-utils": "^15.4.2", - "redux-mock-store": "^1.2.2", - "rimraf": "^2.5.4", - "style-loader": "^0.14.1", - "url-loader": "^0.5.7", - "webpack": "^2.2.1", - "webpack-dev-middleware": "^1.10.0", - "webpack-hot-middleware": "^2.16.1", - "webpack-md5-hash": "^0.0.5" + "react-scripts": "0.9.5", + "redux-mock-store": "^1.2.3" + }, + "scripts": { + "start": "react-scripts start", + "build": "react-scripts build", + "test": "react-scripts test --env=jsdom", + "coverage": "react-scripts test --env=jsdom --coverage", + "eject": "react-scripts eject" } } diff --git a/public/favicon.ico b/public/favicon.ico new file mode 100644 index 0000000..5c125de Binary files /dev/null and b/public/favicon.ico differ diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..65d4db4 --- /dev/null +++ b/public/index.html @@ -0,0 +1,15 @@ + + + + + + + ReactJS CRUD Boilerplate + + +
+ + + + + diff --git a/scripts/build.js b/scripts/build.js deleted file mode 100644 index adff3fe..0000000 --- a/scripts/build.js +++ /dev/null @@ -1,34 +0,0 @@ -/*eslint-disable no-console */ -import webpack from 'webpack'; -import webpackConfig from '../webpack.config.prod'; -import chalk from 'chalk'; - - -process.env.NODE_ENV = 'production'; // this assures the Babel dev config (for hot reloading) doesn't apply. - -console.log(chalk.blue('Generating minified bundle for production. This will take a moment...')); - -webpack(webpackConfig).run((err, stats) => { - if (err) { - console.log(chalk.red(err)); - return 1; - } - - const jsonStats = stats.toJson(); - - if (jsonStats.hasErrors) { - return jsonStats.errors.map(error => console.log(chalk.red(error))); - } - - if (jsonStats.hasWarnings) { - console.log(chalk.yellow('Webpack generated the following warnings: ')); - jsonStats.warnings.map(warning => console.log(chalk.yellow(warning))); - } - - console.log(`Webpack stats: ${stats}`); - - // if we got this far, the build succeeded. - console.log(chalk.green('Your app has been built for production and written to /dist!')); - - return 0; -}); diff --git a/scripts/serverDev.js b/scripts/serverDev.js deleted file mode 100644 index 608a915..0000000 --- a/scripts/serverDev.js +++ /dev/null @@ -1,35 +0,0 @@ -import express from 'express'; -import path from 'path'; -import open from 'open'; -import webpack from 'webpack'; -import webpackConfig from '../webpack.config.dev'; -import chalk from 'chalk'; - - -/* eslint-disable no-console */ -const port = 3000; -const app = express(); -const compiler = webpack(webpackConfig); - - -app.use(require('webpack-dev-middleware')(compiler,{ - noInfo: true, - publicPath: webpackConfig.output.publicPath -})); - -app.use(require("webpack-hot-middleware")(compiler)); - -app.get('*', function(request, response) { - response.sendFile(path.join(__dirname, '../src/index.html')); -}); - - -app.listen(port, function(err) { - if (err) { - console.log(err); - } else { - const uri = `http://localhost:${port}`; - console.log(chalk.green(`Server started on ${uri}`)); //eslint-disable-line no-console - open(uri); - } -}); diff --git a/scripts/serverProd.js b/scripts/serverProd.js deleted file mode 100644 index 822f591..0000000 --- a/scripts/serverProd.js +++ /dev/null @@ -1,30 +0,0 @@ -import express from 'express'; -import path from 'path'; -import open from 'open'; -import compression from 'compression'; - - -/* eslint-disable no-console */ -const port = 3000; -const app = express(); - -//gzip -app.use(compression()); - -//Express to serve static files -app.use(express.static('dist')); - - -app.get('*', function(request, response) { - response.sendFile(path.join(__dirname, '../dist/index.html')); -}); - - - -app.listen(port, function(err) { - if (err) { - console.log(err); - } else { - open(`http://localhost:${port}`); - } -}); diff --git a/scripts/startMessage.js b/scripts/startMessage.js deleted file mode 100644 index 8c1100e..0000000 --- a/scripts/startMessage.js +++ /dev/null @@ -1,3 +0,0 @@ -import chalk from 'chalk'; - -console.log(chalk.green('Starting app in dev mode...')); //eslint-disable-line no-console diff --git a/scripts/testSetup.js b/scripts/testSetup.js deleted file mode 100644 index 72928c5..0000000 --- a/scripts/testSetup.js +++ /dev/null @@ -1,39 +0,0 @@ -// This file is written in ES5/CommonJS sice it's not transpiled by Babel. - -/* eslint-disable no-var*/ - -/* This setting assures the .babelrc dev config doesn't apply for tests. - Also, we don't want to set it to production here for two reasons: - 1. You won't see any PropType validation warnings when code is running in prod mode. - 2. Tests will not display detailed error messages when running against production version code - */ -process.env.NODE_ENV = 'test'; - -// Register babel so that it will transpile ES6 to ES5 before our tests run. -require('babel-register')(); - -// Disable Webpack features that Mocha doesn't understand. -// If Mocha sees this, then just treat it as an empty function. -require.extensions['.css'] = function() {}; -require.extensions['.png'] = function() {}; -require.extensions['.jpg'] = function() {}; - -// Configure JSDOM and set global variables to simulate a browser environment for tests. -var jsdom = require('jsdom').jsdom; - -var exposedProperties = ['window', 'navigator', 'document']; - -global.document = jsdom(''); -global.window = document.defaultView; -Object.keys(document.defaultView).forEach((property) => { - if (typeof global[property] === 'undefined') { - exposedProperties.push(property); - global[property] = document.defaultView[property]; - } -}); - -global.navigator = { - userAgent: 'node.js' -}; - -documentRef = document; //eslint-disable-line no-undef diff --git a/src/action/ActionType.js b/src/action/ActionType.js index 73767cc..9585d93 100644 --- a/src/action/ActionType.js +++ b/src/action/ActionType.js @@ -2,6 +2,7 @@ export const GET_COURSES_RESPONSE = 'GET_COURSES_RESPONSE'; export const GET_COURSE_RESPONSE = 'GET_COURSE_RESPONSE'; export const ADD_NEW_COURSE_RESPONSE = 'ADD_NEW_COURSE_RESPONSE'; export const UPDATE_EXISTING_COURSE_RESPONSE = 'UPDATE_EXISTING_COURSE_RESPONSE'; +export const DELETE_COURSE_RESPONSE = 'DELETE_COURSE_RESPONSE'; export const GET_AUTHORS_RESPONSE = 'GET_AUTHORS_RESPONSE'; diff --git a/src/action/ApiAction.js b/src/action/ApiAction.js index 4b5f661..510f22c 100644 --- a/src/action/ApiAction.js +++ b/src/action/ApiAction.js @@ -2,18 +2,14 @@ import * as ActionType from './ActionType'; -export function ApiCallBeginAction() { - return { - type: ActionType.API_CALL_BEGIN - }; -} +export const ApiCallBeginAction = () => ({ + type: ActionType.API_CALL_BEGIN +}); -export function ApiCallErrorAction() { - return { - type: ActionType.API_CALL_ERROR - }; -} +export const ApiCallErrorAction = () => ({ + type: ActionType.API_CALL_ERROR +}); diff --git a/src/action/AuthorAction.js b/src/action/AuthorAction.js index 8af19a4..a022507 100644 --- a/src/action/AuthorAction.js +++ b/src/action/AuthorAction.js @@ -1,14 +1,12 @@ import * as ActionType from './ActionType'; import AuthorApi from '../api/AuthorApi'; -import {ApiCallBeginAction} from './ApiAction'; +import { ApiCallBeginAction } from './ApiAction'; -export function getAuthorsResponse(authors) { - return { - type: ActionType.GET_AUTHORS_RESPONSE, - authors - }; -} +export const getAuthorsResponse = authors => ({ + type: ActionType.GET_AUTHORS_RESPONSE, + authors +}); diff --git a/src/action/CourseAction.js b/src/action/CourseAction.js index 556231b..918446b 100644 --- a/src/action/CourseAction.js +++ b/src/action/CourseAction.js @@ -1,19 +1,19 @@ import * as ActionType from './ActionType'; import CourseApi from '../api/CourseApi'; -import {ApiCallBeginAction, ApiCallErrorAction} from './ApiAction'; +import { ApiCallBeginAction, ApiCallErrorAction } from './ApiAction'; -export function getCoursesResponse(courses) { - return { - type: ActionType.GET_COURSES_RESPONSE, - courses - }; -} + + +export const getCoursesResponse = courses => ({ + type: ActionType.GET_COURSES_RESPONSE, + courses +}); export function getCoursesAction() { - return(dispatch) => { - + return (dispatch) => { + dispatch(ApiCallBeginAction()); return CourseApi.getAllCourses() @@ -27,53 +27,53 @@ export function getCoursesAction() { -export function addNewCourseResponse(course) { - return { - type: ActionType.ADD_NEW_COURSE_RESPONSE, - course - }; -} +export const addNewCourseResponse = () => ({ + type: ActionType.ADD_NEW_COURSE_RESPONSE +}); -export function updateExistingCourseResponse(course) { - return { - type: ActionType.UPDATE_EXISTING_COURSE_RESPONSE, - course - }; -} + +export const updateExistingCourseResponse = () => ({ + type: ActionType.UPDATE_EXISTING_COURSE_RESPONSE +}); + export function saveCourseAction(courseBeingAddedOrEdited) { - return function(dispatch) { + return function (dispatch) { dispatch(ApiCallBeginAction()); //if authorId exists, it means that the course is being edited, therefore update it. //if authorId doesn't exist, it must therefore be new course that is being added, therefore add it return CourseApi.saveCourse(courseBeingAddedOrEdited) - .then(savedCourse => { - courseBeingAddedOrEdited.id ? dispatch(updateExistingCourseResponse(savedCourse)) : dispatch(addNewCourseResponse(savedCourse)); //eslint-disable-line no-unused-expressions + .then(() => { + if (courseBeingAddedOrEdited.id) { + dispatch(updateExistingCourseResponse()); + } else { + dispatch(addNewCourseResponse()); + } + }).then(() => { + dispatch(getCoursesAction()); }).catch(error => { dispatch(ApiCallErrorAction()); - throw(error); + throw (error); }); }; } -export function getCourseResponse(courseFound) { - return { - type: ActionType.GET_COURSE_RESPONSE, - course: courseFound - }; -} +export const getCourseResponse = courseFound => ({ + type: ActionType.GET_COURSE_RESPONSE, + course: courseFound +}); export function getCourseAction(courseId) { - return(dispatch) => { - + return (dispatch) => { + dispatch(ApiCallBeginAction()); return CourseApi.getCourse(courseId) @@ -85,3 +85,26 @@ export function getCourseAction(courseId) { }; } + + +export const deleteCourseResponse = () => ({ + type: ActionType.DELETE_COURSE_RESPONSE +}); + + + +export function deleteCourseAction(courseId) { + return (dispatch) => { + + dispatch(ApiCallBeginAction()); + + return CourseApi.deleteCourse(courseId) + .then(() => { + dispatch(deleteCourseResponse()); + }).then(() => { + dispatch(getCoursesAction()); + }).catch(error => { + throw error; + }); + }; +} \ No newline at end of file diff --git a/test/action/ApiAction.test.js b/src/action/__tests__/ApiAction.test.js similarity index 59% rename from test/action/ApiAction.test.js rename to src/action/__tests__/ApiAction.test.js index 573808e..d164cd1 100644 --- a/test/action/ApiAction.test.js +++ b/src/action/__tests__/ApiAction.test.js @@ -1,29 +1,28 @@ -import {expect} from 'chai'; -import * as ApiActions from '../../src/action/ApiAction'; -import * as ActionType from '../../src/action/ActionType'; +import * as ApiActions from '../ApiAction'; +import * as ActionType from '../ActionType'; describe('ApiAction.test.js', () => { - describe('ApiCallBeginActionCreator', () => { + describe('ApiCallBeginAction Creator', () => { it(`should create action ${ActionType.API_CALL_BEGIN}`, () => { const expectedAction = {type: ActionType.API_CALL_BEGIN}; const actualAction = ApiActions.ApiCallBeginAction(); - expect(actualAction).to.deep.equal(expectedAction); + expect(actualAction).toEqual(expectedAction); }); }); - describe('ApiCallErrorActionCreator', () => { + describe('ApiCallErrorAction Creator', () => { it(`should create action ${ActionType.API_CALL_ERROR}`, () => { const expectedAction = {type: ActionType.API_CALL_ERROR}; const actualAction = ApiActions.ApiCallErrorAction(); - expect(actualAction).to.deep.equal(expectedAction); + expect(actualAction).toEqual(expectedAction); }); }); diff --git a/test/action/AuthorAction.test.js b/src/action/__tests__/AuthorAction.test.js similarity index 75% rename from test/action/AuthorAction.test.js rename to src/action/__tests__/AuthorAction.test.js index 8197891..c45539a 100644 --- a/test/action/AuthorAction.test.js +++ b/src/action/__tests__/AuthorAction.test.js @@ -1,6 +1,5 @@ -import { expect } from 'chai'; -import * as AuthorActions from '../../src/action/AuthorAction'; -import * as ActionType from '../../src/action/ActionType'; +import * as AuthorActions from '../AuthorAction'; +import * as ActionType from '../ActionType'; import thunk from 'redux-thunk'; import nock from 'nock'; import configureMockStore from 'redux-mock-store'; @@ -9,7 +8,7 @@ import configureMockStore from 'redux-mock-store'; describe('AuthorAction.test.js', () => { - describe('getAuthorsResponseActionCreator', () => { + describe('getAuthorsResponseAction Creator', () => { it(`should create action ${ActionType.GET_AUTHORS_RESPONSE}`, () => { const authors = { id: 'scott-allen', firstName: 'Scott', lastName: 'Allen' }; const expectedAction = { @@ -19,7 +18,7 @@ describe('AuthorAction.test.js', () => { const actualAction = AuthorActions.getAuthorsResponse(authors); - expect(actualAction).to.deep.equal(expectedAction); + expect(actualAction).toEqual(expectedAction); }); }); @@ -28,7 +27,7 @@ describe('AuthorAction.test.js', () => { const mockStore = configureMockStore(thunkMiddleware); - describe('getCoursesActionThunk', () => { + describe('getCoursesAction Thunk', () => { afterEach(() => { nock.cleanAll(); }); @@ -47,12 +46,13 @@ describe('AuthorAction.test.js', () => { ]; const store = mockStore({ authors: [] }, expectedActions, done); + store.dispatch(AuthorActions.getAuthorsAction()) .then(() => { const actions = store.getActions(); - expect(actions[0].type).to.equal(ActionType.API_CALL_BEGIN); - expect(actions[1].type).to.equal(ActionType.GET_AUTHORS_RESPONSE); + expect(actions[0].type).toEqual(ActionType.API_CALL_BEGIN); + expect(actions[1].type).toEqual(ActionType.GET_AUTHORS_RESPONSE); done(); }); }); diff --git a/test/action/CourseAction.test.js b/src/action/__tests__/CourseAction.test.js similarity index 59% rename from test/action/CourseAction.test.js rename to src/action/__tests__/CourseAction.test.js index 3fec879..71277da 100644 --- a/test/action/CourseAction.test.js +++ b/src/action/__tests__/CourseAction.test.js @@ -1,15 +1,14 @@ -import { expect } from 'chai'; import thunk from 'redux-thunk'; import nock from 'nock'; import configureMockStore from 'redux-mock-store'; -import * as CourseActions from '../../src/action/CourseAction'; -import * as ActionType from '../../src/action/ActionType'; +import * as CourseActions from '../CourseAction'; +import * as ActionType from '../ActionType'; describe('CourseAction.test.js', () => { - describe('getCoursesResponseActionCreator', () => { + describe('getCoursesResponseAction Creator', () => { it(`should create action ${ActionType.GET_COURSES_RESPONSE}`, () => { const courses = [{ title: 'Learn reactjs redux' }]; const expectedAction = { @@ -19,60 +18,79 @@ describe('CourseAction.test.js', () => { const actualAction = CourseActions.getCoursesResponse(courses); - expect(actualAction).to.deep.equal(expectedAction); + expect(actualAction).toEqual(expectedAction); }); }); - describe('addNewCourseResponseActionCreator', () => { + describe('addNewCourseResponseAction Creator', () => { it(`should create action ${ActionType.ADD_NEW_COURSE_RESPONSE}`, () => { const course = { title: 'Learn reactjs redux' }; const expectedAction = { - type: ActionType.ADD_NEW_COURSE_RESPONSE, - course: course + type: ActionType.ADD_NEW_COURSE_RESPONSE }; const actualAction = CourseActions.addNewCourseResponse(course); - expect(actualAction).to.deep.equal(expectedAction); + expect(actualAction).toEqual(expectedAction); }); }); - describe('updateExistingCourseResponseActionCreator', () => { + describe('updateExistingCourseResponseAction Creator', () => { it(`should create action ${ActionType.UPDATE_EXISTING_COURSE_RESPONSE}`, () => { const course = { title: 'Learn reactjs redux' }; const expectedAction = { - type: ActionType.UPDATE_EXISTING_COURSE_RESPONSE, - course: course + type: ActionType.UPDATE_EXISTING_COURSE_RESPONSE }; const actualAction = CourseActions.updateExistingCourseResponse(course); - expect(actualAction).to.deep.equal(expectedAction); + expect(actualAction).toEqual(expectedAction); + }); + }); + + + describe('getCourseResponseAction Creator', () => { + it(`should create action ${ActionType.GET_COURSE_RESPONSE}`, () => { + const course = { title: 'Learn reactjs redux' }; + const expectedAction = { + type: ActionType.GET_COURSE_RESPONSE, + course: course + }; + + const actualAction = CourseActions.getCourseResponse(course); + + expect(actualAction).toEqual(expectedAction); + }); + }); + + + + describe('deleteCourseResponseAction Creator', () => { + it(`should create action ${ActionType.DELETE_COURSE_RESPONSE}`, () => { + const expectedAction = { + type: ActionType.DELETE_COURSE_RESPONSE + }; + + const actualAction = CourseActions.deleteCourseResponse(); + + expect(actualAction).toEqual(expectedAction); }); }); + const thunkMiddleware = [thunk]; const mockStore = configureMockStore(thunkMiddleware); - describe('getCoursesActionThunk', () => { + describe('getCoursesAction Thunk', () => { afterEach(() => { nock.cleanAll(); }); it('should get all courses', (done) => { - // nock Example (note: our API is a mock, so no need to use nock here) : - // nock('http://example.com') - // .get('/courses') - // .reply(200, { body: - // { - // course: [ { id: 1, firstName: 'Ben', lastName: 'Stuart' } ] - // } - // }); - const expectedActions = [ { type: ActionType.API_CALL_BEGIN }, { @@ -86,12 +104,13 @@ describe('CourseAction.test.js', () => { ]; const store = mockStore({ courses: [] }, expectedActions, done); + store.dispatch(CourseActions.getCoursesAction()) .then(() => { const actions = store.getActions(); - expect(actions[0].type).to.equal(ActionType.API_CALL_BEGIN); - expect(actions[1].type).to.equal(ActionType.GET_COURSES_RESPONSE); + expect(actions[0].type).toEqual(ActionType.API_CALL_BEGIN); + expect(actions[1].type).toEqual(ActionType.GET_COURSES_RESPONSE); done(); }); }); @@ -99,7 +118,7 @@ describe('CourseAction.test.js', () => { }); - describe('saveCourseActionThunk', () => { + describe('saveCourseAction Thunk', () => { afterEach(() => { nock.cleanAll(); }); @@ -107,24 +126,17 @@ describe('CourseAction.test.js', () => { it('should update existing course', (done) => { const expectedActions = [ { type: ActionType.API_CALL_BEGIN }, - { - type: ActionType.UPDATE_EXISTING_COURSE_RESPONSE, - body: { - course: [ - { id: 1, title: 'Java Clean Code' } - ] - } - } + { type: ActionType.UPDATE_EXISTING_COURSE_RESPONSE} ]; const store = mockStore({ course: [] }, expectedActions, done); - const course = { id: 1, title: 'Learn reactjs redux' }; + const course = { id: 1, title: 'Learn reactjs redux' }; store.dispatch(CourseActions.saveCourseAction(course)) .then(() => { const actions = store.getActions(); - expect(actions[0].type).to.equal(ActionType.API_CALL_BEGIN); - expect(actions[1].type).to.equal(ActionType.UPDATE_EXISTING_COURSE_RESPONSE); + expect(actions[0].type).toEqual(ActionType.API_CALL_BEGIN); + expect(actions[1].type).toEqual(ActionType.UPDATE_EXISTING_COURSE_RESPONSE); done(); }); }); @@ -133,50 +145,26 @@ describe('CourseAction.test.js', () => { it('should add a new course', (done) => { const expectedActions = [ { type: ActionType.API_CALL_BEGIN }, - { - type: ActionType.ADD_NEW_COURSE_RESPONSE, - body: { - course: [ - { title: 'Java Clean Code' } - ] - } - } + { type: ActionType.ADD_NEW_COURSE_RESPONSE} ]; const store = mockStore({ course: [] }, expectedActions, done); - const course = { title: 'Learn reactjs redux' }; + const course = { title: 'Learn reactjs redux' }; store.dispatch(CourseActions.saveCourseAction(course)) .then(() => { const actions = store.getActions(); - expect(actions[0].type).to.equal(ActionType.API_CALL_BEGIN); - expect(actions[1].type).to.equal(ActionType.ADD_NEW_COURSE_RESPONSE); + expect(actions[0].type).toEqual(ActionType.API_CALL_BEGIN); + expect(actions[1].type).toEqual(ActionType.ADD_NEW_COURSE_RESPONSE); done(); }); - }); - - }); - - - - - describe('getCourseResponseActionCreator', () => { - it(`should create action ${ActionType.GET_COURSE_RESPONSE}`, () => { - const course = { title: 'Learn reactjs redux' }; - const expectedAction = { - type: ActionType.GET_COURSE_RESPONSE, - course: course - }; - - const actualAction = CourseActions.getCourseResponse(course); - - expect(actualAction).to.deep.equal(expectedAction); }); + }); - describe('getCourseActionThunk', () => { + describe('getCourseAction Thunk', () => { afterEach(() => { nock.cleanAll(); }); @@ -185,9 +173,7 @@ describe('CourseAction.test.js', () => { const findThisCourse = { id: 1, title: 'Java Clean Code' }; const expectedActions = [ - { - type: ActionType.API_CALL_BEGIN - }, + { type: ActionType.API_CALL_BEGIN }, { type: ActionType.GET_COURSE_RESPONSE, body: { @@ -201,15 +187,42 @@ describe('CourseAction.test.js', () => { .then(() => { const actions = store.getActions(); - expect(actions[0].type).to.equal(ActionType.API_CALL_BEGIN); - expect(actions[1].type).to.equal(ActionType.GET_COURSE_RESPONSE); + expect(actions[0].type).toEqual(ActionType.API_CALL_BEGIN); + expect(actions[1].type).toEqual(ActionType.GET_COURSE_RESPONSE); done(); }); }); + }); + + + describe('deleteCourseAction Thunk', () => { + afterEach(() => { + nock.cleanAll(); + }); + + it('should delete a specific course', (done) => { + const expectedActions = [ + { type: ActionType.API_CALL_BEGIN }, + { + type: ActionType.DELETE_COURSE_RESPONSE + } + ]; + + const store = mockStore({ course: {} }, expectedActions, done); + store.dispatch(CourseActions.deleteCourseAction(1)) + .then(() => { + const actions = store.getActions(); + + expect(actions[0].type).toEqual(ActionType.API_CALL_BEGIN); + expect(actions[1].type).toEqual(ActionType.DELETE_COURSE_RESPONSE); + done(); + }); + }); }); -}); +}); + diff --git a/src/components/About.js b/src/components/About.js index b1fec8c..4f09d3e 100644 --- a/src/components/About.js +++ b/src/components/About.js @@ -1,13 +1,14 @@ import React from 'react'; -export default class About extends React.Component { - render() { - return ( -
-

About

-

Est et amet perfecto sententiae, nec error essent eripuit ei. Velit sanctus ut has, partem dolorem atomorum est ad, sumo fabellas electram ex vim.

-
- ); - } -} +const About = () => { + return ( +
+

About

+

Est et amet perfecto sententiae, nec error essent eripuit ei. Velit sanctus ut has, partem dolorem atomorum est ad, sumo fabellas electram ex vim.

+
+ ); +}; + + +export default About; \ No newline at end of file diff --git a/src/components/App.js b/src/components/App.js index c3a4bfb..4cf181a 100644 --- a/src/components/App.js +++ b/src/components/App.js @@ -1,5 +1,5 @@ import React from 'react'; -import {BrowserRouter as Router, Route, Switch} from 'react-router-dom'; +import { BrowserRouter as Router, Route, Switch } from 'react-router-dom'; import PageNotFound from './common/PageNotFound'; import Home from './landing/Home'; import CourseListContainer from './course/CourseListContainer'; // eslint-disable-line import/no-named-as-default @@ -13,29 +13,29 @@ import HeaderNavContainer from './landing/HeaderNavContainer'; // eslint-disable const history = createBrowserHistory(); +const App = () => { + return ( +
+ +
-export default class App extends React.Component { - render() { - return ( -
- -
- - - - - - - - - - - - -
- -
-
- ); - } -} + + + + + + + + + + + +
+ +
+
+ ); +}; + + +export default App; \ No newline at end of file diff --git a/src/components/__tests__/About.test.js b/src/components/__tests__/About.test.js new file mode 100644 index 0000000..74c50d7 --- /dev/null +++ b/src/components/__tests__/About.test.js @@ -0,0 +1,17 @@ +import React from 'react'; +import {shallow} from 'enzyme'; +import toJson from 'enzyme-to-json'; +import About from '../About'; + + +describe('About.test.js', () => { + + it('renders as expected', () => { + const wrapper = shallow(); + + expect(wrapper.length).toEqual(1); + + const tree = toJson(wrapper); + expect(tree).toMatchSnapshot(); + }); +}); diff --git a/src/components/__tests__/__snapshots__/About.test.js.snap b/src/components/__tests__/__snapshots__/About.test.js.snap new file mode 100644 index 0000000..896fd28 --- /dev/null +++ b/src/components/__tests__/__snapshots__/About.test.js.snap @@ -0,0 +1,11 @@ +exports[`About.test.js renders as expected 1`] = ` +
+

+ About +

+

+ Est et amet perfecto sententiae, nec error essent eripuit ei. Velit sanctus ut has, partem dolorem atomorum est ad, sumo fabellas electram ex vim. +

+
+`; diff --git a/src/components/common/FieldInput.js b/src/components/common/FieldInput.js index e615403..ab02376 100644 --- a/src/components/common/FieldInput.js +++ b/src/components/common/FieldInput.js @@ -1,12 +1,8 @@ import React, {PropTypes} from 'react'; -class FieldInput extends React.Component { - - render() { - const {input, type, name, label, placeholder, meta: {touched, error, warning}} = this.props; - - return( +const FieldInput = ({input, type, name, label, placeholder, meta: {touched, error, warning}}) => { + return(
@@ -22,10 +18,8 @@ class FieldInput extends React.Component { {touched && ((error &&

{error}

) || (warning &&

{warning}

))}
- ); - } - -} + ); +}; @@ -39,4 +33,5 @@ FieldInput.propTypes = { }; + export default FieldInput; diff --git a/src/components/common/PageNotFound.js b/src/components/common/PageNotFound.js index 041ece7..6d278da 100644 --- a/src/components/common/PageNotFound.js +++ b/src/components/common/PageNotFound.js @@ -1,8 +1,7 @@ import React, {PropTypes} from 'react'; - -export default function PageNotFound({location}) { +const PageNotFound = ({location}) => { return (

Page Not Found

@@ -10,8 +9,14 @@ export default function PageNotFound({location}) {

No match for the link {location.pathname}

); -} +}; + + PageNotFound.propTypes = { location: PropTypes.object.isRequired }; + + + +export default PageNotFound; diff --git a/src/components/common/SelectInput.js b/src/components/common/SelectInput.js index f15a0d3..aefe55f 100644 --- a/src/components/common/SelectInput.js +++ b/src/components/common/SelectInput.js @@ -1,6 +1,7 @@ import React, {PropTypes} from 'react'; -export default function SelectInput({input, name, label, defaultOption, options, meta: {touched, error, warning}}) { + +const SelectInput = ({input, name, label, defaultOption, options, meta: {touched, error, warning}}) => { return(
@@ -24,7 +25,7 @@ export default function SelectInput({input, name, label, defaultOption, options,
); -} +}; @@ -36,3 +37,6 @@ SelectInput.propTypes = { options: PropTypes.arrayOf(PropTypes.object), meta: PropTypes.object.isRequired }; + + +export default SelectInput; \ No newline at end of file diff --git a/src/components/common/__tests__/FieldInput.test.js b/src/components/common/__tests__/FieldInput.test.js new file mode 100644 index 0000000..54d25e7 --- /dev/null +++ b/src/components/common/__tests__/FieldInput.test.js @@ -0,0 +1,49 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import toJson from 'enzyme-to-json'; +import FieldInput from '../FieldInput'; + + + +let props = undefined; + +beforeAll(() => { + props = { + input: {}, + type: 'text', + name: 'category', + label: 'Category', + placeholder: 'Category', + meta: { touched: false, error: {}, warning: {} }, + onChange: jest.fn() + }; + + return props; +}); + + + +describe('FieldInput.test.js', () => { + let wrapper = undefined; + + beforeEach(() => { + wrapper = shallow(); + return wrapper; + }); + + + it('renders without crashing', () => { + expect(wrapper.length).toEqual(1); + }); + + + it('renders as expected', () => { + const tree = toJson(wrapper); + expect(tree).toMatchSnapshot(); + }); + + +}); + + + diff --git a/src/components/common/__tests__/PageNotFound.test.js b/src/components/common/__tests__/PageNotFound.test.js new file mode 100644 index 0000000..384c4b1 --- /dev/null +++ b/src/components/common/__tests__/PageNotFound.test.js @@ -0,0 +1,36 @@ +import React from 'react'; +import {shallow} from 'enzyme'; +import toJson from 'enzyme-to-json'; +import PageNotFound from '../PageNotFound'; + + +let props = undefined; + + +beforeAll(() => { + props = { location:{pathName: '/blah'}}; + return props; +}); + + + +describe('PageNotFound.test.js', () => { + let wrapper = undefined; + + beforeEach(() => { + wrapper = shallow(); + return wrapper; + }); + + it('renders without crashing', () => { + expect(wrapper.length).toEqual(1); + }); + + + it('renders as expected', () => { + const tree = toJson(wrapper); + expect(tree).toMatchSnapshot(); + }); + + +}); diff --git a/src/components/common/__tests__/SelectInput.test.js b/src/components/common/__tests__/SelectInput.test.js new file mode 100644 index 0000000..70b60e2 --- /dev/null +++ b/src/components/common/__tests__/SelectInput.test.js @@ -0,0 +1,44 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import toJson from 'enzyme-to-json'; +import SelectInput from '../SelectInput'; + + +let props = undefined; + + +beforeAll(() => { + props = { + input: {}, + name: 'category', + label: 'Category', + defaultOption: '', + options: [{ value: 'Josh', text: 'Bloch' }], + meta: { touched: false, error: {}, warning: {} }, + }; + + return props; +}); + + + +describe('SelectInput.test.js', () => { + let wrapper = undefined; + + beforeEach(() => { + wrapper = shallow(); + return wrapper; + }); + + it('renders without crashing', () => { + expect(wrapper.length).toEqual(1); + }); + + + it('renders as expected', () => { + const tree = toJson(wrapper); + expect(tree).toMatchSnapshot(); + }); + + +}); diff --git a/src/components/common/__tests__/Spinner.test.js b/src/components/common/__tests__/Spinner.test.js new file mode 100644 index 0000000..c6f0a44 --- /dev/null +++ b/src/components/common/__tests__/Spinner.test.js @@ -0,0 +1,26 @@ +import React from 'react'; +import {shallow} from 'enzyme'; +import toJson from 'enzyme-to-json'; +import Spinner from '../Spinner'; + + + +describe('Spinner.test.js', () => { + let wrapper = undefined; + + beforeEach(() => { + wrapper = shallow(); + return wrapper; + }); + + + it('renders without crashing', () => { + expect(wrapper.length).toEqual(1); + }); + + + it('renders as expected', () => { + const tree = toJson(wrapper); + expect(tree).toMatchSnapshot(); + }); +}); diff --git a/src/components/common/__tests__/__snapshots__/FieldInput.test.js.snap b/src/components/common/__tests__/__snapshots__/FieldInput.test.js.snap new file mode 100644 index 0000000..57c56c6 --- /dev/null +++ b/src/components/common/__tests__/__snapshots__/FieldInput.test.js.snap @@ -0,0 +1,17 @@ +exports[`FieldInput.test.js renders as expected 1`] = ` +
+ +
+ +
+
+`; diff --git a/src/components/common/__tests__/__snapshots__/PageNotFound.test.js.snap b/src/components/common/__tests__/__snapshots__/PageNotFound.test.js.snap new file mode 100644 index 0000000..632829c --- /dev/null +++ b/src/components/common/__tests__/__snapshots__/PageNotFound.test.js.snap @@ -0,0 +1,17 @@ +exports[`PageNotFound.test.js renders as expected 1`] = ` +
+

+ Page Not Found +

+

+ 404 Error +

+

+ No match for the link + +

+
+`; diff --git a/src/components/common/__tests__/__snapshots__/SelectInput.test.js.snap b/src/components/common/__tests__/__snapshots__/SelectInput.test.js.snap new file mode 100644 index 0000000..d1ff0a5 --- /dev/null +++ b/src/components/common/__tests__/__snapshots__/SelectInput.test.js.snap @@ -0,0 +1,21 @@ +exports[`SelectInput.test.js renders as expected 1`] = ` +
+
+ Category +
+
+ +
+
+`; diff --git a/src/components/common/__tests__/__snapshots__/Spinner.test.js.snap b/src/components/common/__tests__/__snapshots__/Spinner.test.js.snap new file mode 100644 index 0000000..34b49a1 --- /dev/null +++ b/src/components/common/__tests__/__snapshots__/Spinner.test.js.snap @@ -0,0 +1,7 @@ +exports[`Spinner.test.js renders as expected 1`] = ` +

+ . +   +

+`; diff --git a/src/components/course/AddOrEditCourseContainer.js b/src/components/course/AddOrEditCourseContainer.js index 7411252..489a781 100644 --- a/src/components/course/AddOrEditCourseContainer.js +++ b/src/components/course/AddOrEditCourseContainer.js @@ -1,11 +1,11 @@ -import React, {PropTypes} from 'react'; -import {connect} from 'react-redux'; -import {bindActionCreators} from 'redux'; +import React, { PropTypes } from 'react'; +import { connect } from 'react-redux'; +import { bindActionCreators } from 'redux'; import toastr from 'toastr'; import * as courseAction from '../../action/CourseAction'; import * as authorAction from '../../action/AuthorAction'; import CourseForm from './CourseForm'; // eslint-disable-line import/no-named-as-default -import {authorsFormattedForDropdown} from '../../selectors/selectors'; // eslint-disable-line import/no-named-as-default +import { authorsFormattedForDropdown } from '../../selectors/selectors'; // eslint-disable-line import/no-named-as-default export class AddOrEditCourseContainer extends React.Component { @@ -23,8 +23,8 @@ export class AddOrEditCourseContainer extends React.Component { this.props.action.getCourseAction(this.props.match.params.id) .catch(error => { toastr.error(error); - }); - + }); + this.props.action.getAuthorsAction() .catch(error => { toastr.error(error); @@ -49,7 +49,7 @@ export class AddOrEditCourseContainer extends React.Component { this.props.history.push('/courses'); }).catch(error => { toastr.error(error); - }); + }); } @@ -60,28 +60,28 @@ export class AddOrEditCourseContainer extends React.Component { } - + render() { - const {initialValues} = this.props; + const { initialValues } = this.props; const heading = initialValues && initialValues.id ? 'Edit' : 'Add'; - return( + return (
- -
+ ); } } -function mapStateToProps(state, ownProps) { +const mapStateToProps = (state, ownProps) => { const courseId = ownProps.match.params.id; //from the path '/course/:id' if (courseId && state.selectedCourseReducer.course && courseId === state.selectedCourseReducer.course.id) { @@ -94,15 +94,14 @@ function mapStateToProps(state, ownProps) { authors: authorsFormattedForDropdown(state.authorReducer.authors) }; } -} +}; -function mapDispatchToProps(dispatch) { - return { - action: bindActionCreators({...authorAction, ...courseAction}, dispatch) - }; -} +const mapDispatchToProps = dispatch => ({ + action: bindActionCreators({ ...authorAction, ...courseAction }, dispatch) +}); + AddOrEditCourseContainer.propTypes = { diff --git a/src/components/course/CourseForm.js b/src/components/course/CourseForm.js index e9509e7..66c0fda 100644 --- a/src/components/course/CourseForm.js +++ b/src/components/course/CourseForm.js @@ -1,60 +1,56 @@ -import React, {PropTypes} from 'react'; +import React, { PropTypes } from 'react'; import { Field, reduxForm } from 'redux-form'; import FieldInput from '../common/FieldInput'; import SelectInput from '../common/SelectInput'; -export class CourseForm extends React.Component { - - render() { - const {handleSubmit, pristine, reset, submitting, heading, authors, handleSave, handleCancel} = this.props; - - return ( -
-

{heading}

- - - - - - - - - -
-