diff --git a/.babelrc b/.babelrc new file mode 100644 index 0000000..2a4a32d --- /dev/null +++ b/.babelrc @@ -0,0 +1,6 @@ +{ + "presets": [ + "es2015", + "react" + ] +} \ No newline at end of file diff --git a/Brian Task List.txt b/Brian Task List.txt new file mode 100644 index 0000000..7b6accb --- /dev/null +++ b/Brian Task List.txt @@ -0,0 +1,132 @@ +[] Unit Test with Mocha in order of importance + [] Components + [x] ModalConfirm + [] Reducers + [] Selectors + [] Action Creators + [] JS DOM + [] REST API + [x] Research Mockgoose. It looks like it requires MongoDB to be installed locally. + [] Configure an ENV variable within Unit Test and adapt REST service. + [] Since MongoDB is in Cloud, setup another "testDB" where the default data script can be loaded each time. + [] Modify the "defaultData" method to insert addtional "flags" within the array on each "site" document. + [] Microservices: Configure a consumer contract and Pact broker + +[] Integrate application with Web Components or React Components for the "Date Pickers" +[x] Finish wiring up the DELETE actions and update the server through a POST. + [x] Make sure that the reducers clear out any array indexes after the server responds because the positions may have changed. + +[x] Create a generic a component that can be used for "Delete Flag". +[x] Find out why the JSX List is not matching to the Filtered array (by date).... weird, weird, weird. +[x] Reset the Edit/New form on the state after a successful update. +[x] The Edit functionality is not selecting the default option in the list. +[x] Need to solve a bug dealing with "skipped records". Currently the SiteDetails will print a message if there are no Flags in the database for the given site. However it's possible that the component will return NULL if the EndDate exists in the past. +[x] Create a dummy FlagRow component that will wire back the click events for EDIT/DELETE +[x] Create a FlagRowContainer that will take care of rendering the Dummy Flag row, and also any EDIT functionality. +[] Sort the results from the SiteList rest call. Mongo is putting the most recently saved document to the bottom. +[] When selecting a Site, make sure to clear out any "hasErrorSaving" flag. Otherwise an error message could be hanging around from the last attempt +[x] Solve MongoDB error when trying to update the Site Object: 'Modifiers operate on fields but we found type string instead. For example: {$mod: {: ...}} not {$set: "{"flags":[{"flagType":"Retailer - Location Priority","startDate":"8/25/18","endDate":""}]}"}', +[x] Wire up the REST call for creating a new Flag from within the AddFlagContainer component. + [] Solve error on REST service: Method PUT is not allowed by Access-Control-Allow-Methods in preflight response. + [x] Just change the PUT route to a POST for now. + [x] Find out why JSON body is not showing up on the server through req.body. + [x] Install the Body Parser middle-ware for Express. + [x] Find out why FETCH is doing a pre-flight request (i.e. OPTIONS whenever ApplicationType JSON is put in the headers) + [x] Figure out a way to use BodyParser with "text/plain" or write a custom middleware to chunk the req.body. + +[x] Add form validation to the AddOrEditFlag dumb component. +[] Clean up all of the state properties on the individual Sites array when the SiteDetailList is closed within the Reducer (likely by brute force). For example, if the Add Flag form is open on a view, then it appears open when re-visiting the same site details page. +[x] Fix weird warning that happens when canceling the AddFlag Form: "Form submission canceled because the form is not connected" +[] Find out why HotReloading doesn't work within a Route (on the Site Details page). +[x] Solve error from AddFlagContainerComponent: React.createElement: type is invalid -- expected a string (for built-in components) or a class/function (for composite components) +[x] Solve infinite loop that started happening after clicking into a SiteDetails with an AddEditRow component. +[x] Abstract the Form for Add/Edit flag so that it can be re-used for both types (like a New/Edit registration page). +[x] Ensure that all Components have PropTypes configured. +[x] Design process for processing the Flag objects in an array on the Site Details page. + [x] Create objects which accept a SiteID and a FlagIndex as a prop. That way there's no need to pass forward/back callbacks and pollute the SiteDetailsContainer. + +[x] Come up with a way to un-select the current site by routing ing back to the main page. +[] Refactor the Reducer for SELECT_SITE. It doesn't need to send a flag in the payload now because another action will be used for un-selecting. +[x] Find out why Selector for getSelectedSiteIsLoaded is returning stale data from the state. +[x] Solve error connecting to MongoDB. Just happened out of the blue: "Topology was destroyed" +[x] Solve problem of Selected SiteID not updating from change to Router parameters. +[] Currently the SitesList is going to re-render for every change to a child component. Consider making the SiteList just an array of SiteID,Name within a new selector to keep the component stable. +[x] Find out with the SiteDetailsContainer is not re-rendering after a change to the State. +[] Refactor codebase to get rid of inconsistent use of "siteList..." versus "sitesList". +[x] Store the selected Site ID on the State after a Route change so that the Site Details can be memoized in various selectors. + [x] Move the info about the SiteList, such as error, inProgress, etc. into its own Key on the state and combine reducers. + +[x] Refactor the dispatch calls for "isLoading(false)" on SiteDeails Load/Save. The Reducers can infer when the action for the Response or Error is given. +[] Find out why the ES7 "rest" operators is reporting a syntax error in the browser. Chrome works with the same syntax natively. Maybe it's an ESlint thing? +[] Confirm that WebPack will strip out console.log() statements in production. +[x] Solve Double-load problem on SiteListContainer component. + [x] Figure out why MapStateToProps is not reflecting the state returned from the reducer in the Container method. + [x] Explain why there seems to be 2 separate Redux stores. + [x] Find out why the entry page (/app/js/app.js) is executed twice. + +[x] Add CORS support for the REST service. +[x] Fix error that started showing up: Module not found: Error: Can't resolve 'whatwg-fetch' in ... + [x] I had to take out the Pollyfill I created for "fetch" in the WebPack config. Maybe I need to import another library like Axios or manually pollyfill? + +[x] Create a Container component for the SiteList that will take care of loading the data from the API if the state is empty. +[] Setup Decorators to validate function inputs, like TypeScript. +[x] Stub out actions with basic Thunk workflow. + [x] Decide on convention for Action types. SFA + [x] How to deal with Errors on PUT "SiteData" which has a SiteID associated with the Payload. + [x] Create Thunk Aysnc Actions for GetSiteList, GetSiteData(SiteID), SaveSiteData(siteId) + +[x] Decide on a process for loading data from the server and saving mutations. +[x] Design shape of reducers +[] Find a way to keep the "key" entries unique on components within Site Details view. +[] Figure out how to use Nomalizr with "flag" entries. They don't have unique ID's on their own. +[] Create REST API + [x] Store DB credentials within an ENV variable or maybe a flat file, just to demonstrate a consideration for security. + [] Get DB Access working with Promises. + [x] Populate MongoDB with a temp script. + [x] Figure out why I can't perform a "findOne()" by _id. + [x] Create a GET route for /sites + [x] Create a GET route for /sites/:id which returns a single Site document. + [x] Create a PUT route for /sites/:id which updates the single Site document. + [x] Verify that the document exists first, no upserting. + [x] Validate contents of the Site document. + [] Setup Mongoose to validate the Site Update route. + +[x] Configure server-side framework + [x] Install Express + [x] Install Mongo + [x] Sign up for a MongoDB provider in the cloud. + [x] Verify connection to Mongo server on startup of Express app. + +[] Configure client-side framework + [x] Install WebPack DevServer + [x] Install Babel and configure .babelrc + [x] Install React and React-DOM + [x] Create test file for /app/js/app.js + [x] Make sure that running "webpack" (global) will output to DIST. + [x] Install file-loader. + [x] Solve WebPack error: ERROR in Entry module not found: Error: Can't resolve 'file' in '\Projects\full-stack-coding-exercise/app' + [x] Solve issue with "Multiple assets emit to the same filename" + [x] Install html-webpack-plugin instead of using file-loader. + [] Find out why WebPack 2 doesn't use a "root" for module dependencies. How important is this over using relative paths? + [X] Install ESLint... Use AirBnB's rules: https://github.com/airbnb/javascript/tree/master/packages/eslint-config-airbnb + [x] Install React Hot Reloader + [x] Fix error after insalling Hot Reloader to WebPack: 'import' and 'export' may only appear at the top level (3:0) + [x] Install React-Router + [x] Create 2 basic routes for the SiteList and SiteDetail/:slug: + [x] Install Redux + [] Install Normalizr + [] Install Immutable.js + [] Install CSS framework + [x] Install React-Thunk + [] Install React-Promises + [x] Pollyfill the "fetch" and "promise" using WebPack, doesn't look like Babel does this. + [] Verify that Pollyfill worked, even if WebPack compiled OK. + [x] Install "prop-types" + +[x] Research CSS Solution. CSS Modules ... Styled Components??? +[x] Get JSX / Babel Package for SublimeText +[] Figure out why Sublime Text is not honoring the .editorconfig IDE settings. + [] Figure out why .sublime_project is not allowing Tabbed spaces. + +[x] Setup local repo with a Fork and GitFlow +[x] Go through Readme and clarify any ambiguity. exercise \ No newline at end of file diff --git a/app/index.html b/app/index.html new file mode 100644 index 0000000..7c49c39 --- /dev/null +++ b/app/index.html @@ -0,0 +1,11 @@ + + + + GSTV Full Stack Coding Exercise + + + +
+ + + \ No newline at end of file diff --git a/app/js/actions/actionObjects.js b/app/js/actions/actionObjects.js new file mode 100644 index 0000000..11379fa --- /dev/null +++ b/app/js/actions/actionObjects.js @@ -0,0 +1,60 @@ +import * as actionTypes from './actionTypes.js' + + +export function confirmDeleteShow(siteId, flagIndex) { + return { + type: actionTypes.CONFIRM_DELETE_FLAG, + payload: { siteId, flagIndex, show:true } + } +} + +export function confirmDeleteHide(siteId, flagIndex) { + return { + type: actionTypes.CONFIRM_DELETE_FLAG, + payload: { siteId, flagIndex, show:false } + } +} + +export function selectSite(siteId) { + return { + type: actionTypes.SELECT_SITE, + payload: { siteId, show:true } + } +} + +export function unselectActiveSite() { + return { + type: actionTypes.UNSELECT_ACTIVE_SITE, + payload: { } + } +} + +export function editFlag(siteId, flagIndex) { + return { + type: actionTypes.EDIT_FLAG, + payload: { siteId, flagIndex, show:true } + } +} + +export function editFlagCancel(siteId, flagIndex) { + return { + type: actionTypes.EDIT_FLAG, + payload: { siteId, flagIndex, show:false } + } +} + +export function addFlag(siteId) { + return { + type: actionTypes.ADD_FLAG, + payload: { siteId, show:true } + } +} + +export function addFlagCancel(siteId) { + return { + type: actionTypes.ADD_FLAG, + payload: { siteId, show:false } + } +} + + diff --git a/app/js/actions/actionTypes.js b/app/js/actions/actionTypes.js new file mode 100644 index 0000000..5875366 --- /dev/null +++ b/app/js/actions/actionTypes.js @@ -0,0 +1,18 @@ +export const EDIT_FLAG = 'EDIT_FLAG' + +export const ADD_FLAG = 'ADD_FLAG' + +export const CONFIRM_DELETE_FLAG = 'CONFIRM_DELETE_FLAG' + +export const SITE_DATA_LOADING = 'SITE_DATA_LOADING' +export const SITE_DATA_RESPONSE = 'SITE_DATA_RESPONSE' + +export const SITE_DATA_SAVE_SENDING = 'SITE_DATA_SAVE_SENDING' +export const SITE_DATA_SAVE_RESPONSE = 'SITE_DATA_SAVE_RESPONSE' + +export const SITES_LIST_LOADING = 'SITES_LIST_LOADING' +export const SITES_LIST_RESPONSE = 'SITES_LIST_RESPONSE' + +export const SELECT_SITE = 'SELECT_SITE' +export const UNSELECT_ACTIVE_SITE = 'UNSELECT_ACTIVE_SITE' + diff --git a/app/js/actions/thunks.js b/app/js/actions/thunks.js new file mode 100644 index 0000000..561f632 --- /dev/null +++ b/app/js/actions/thunks.js @@ -0,0 +1,167 @@ +import {URL_SITES_LIST} from '../lib/constants.js' +import * as actionTypes from './actionTypes.js' + +// Setup instructions from https://www.npmjs.com/package/fetch-everywhere +// Provides a pollyfill for "fetch" within global scope. +require('es6-promise').polyfill(); +require('fetch-everywhere'); + + +// The "sites list" calls a REST service which returns an array of Site ID's and Site Names (without any Site data). +export function fetchSitesList() { + + return (dispatch) => { + + dispatch(sitesListIsLoading(true)); + + fetch(URL_SITES_LIST) + .then((response) => { + + if (!response.ok) + throw new Error("Error loading sites list: " + response.statusText) + + return response; + }) + .then(response => response.json()) + .then(responseObj => dispatch(sitesListDataSuccess(responseObj)) ) + .catch(error => dispatch(sitesListDataError(error))) + }; +} + +function sitesListIsLoading(bool) { + + return { + type: actionTypes.SITES_LIST_LOADING, + payload: bool + } +} + +function sitesListDataSuccess(sites) { + + return { + type: actionTypes.SITES_LIST_RESPONSE, + payload: sites, + error: false + } +} + +function sitesListDataError(error) { + + console.log("Error fetching data for sites list:", error) + + return { + type: actionTypes.SITES_LIST_RESPONSE, + payload: error, + error: true + } +} + + +// The "site data" is comes from another REST call to populate the flags for a given Site ID. +export function fetchSiteData(siteId) { + + return (dispatch) => { + + dispatch(siteDataIsLoading(siteId, true)); + + fetch(URL_SITES_LIST + "/" + siteId) + .then((response) => { + + if (!response.ok) + throw new Error(`Error loading site data for ID ${siteId}: ${response.statusText}`) + + return response; + }) + .then(response => response.json()) + .then(responseObj => dispatch(siteDataSuccess(siteId, responseObj))) + .catch(error => dispatch(siteDataError(siteId, error))) + }; +} + +function siteDataIsLoading(siteId, isLoading) { + + return { + type: actionTypes.SITE_DATA_LOADING, + payload: { siteId, isLoading } + } +} + +function siteDataSuccess(siteId, siteObj) { + + return { + type: actionTypes.SITE_DATA_RESPONSE, + payload: { siteId, siteObj }, + error: false + } +} + +function siteDataError(siteId, error) { + + console && console.log && console.log("Error fetching data for site data:", error) + + return { + type: actionTypes.SITE_DATA_RESPONSE, + payload: { siteId, error }, + error: true + } +} + + +// Saves the Site Data back to the server. +export function saveSiteData(siteId, siteObj) { + + if(!siteObj || !Array.isArray(siteObj.flags)) + throw new Error("Error with saveSiteData action creator. The given Site Object should contain an array of Flag objects.") + + return (dispatch) => { + + dispatch(siteDataIsSaving(siteId, true)); + + fetch(URL_SITES_LIST + "/" + siteId, { + method: 'POST', + body: JSON.stringify({ + flags: siteObj.flags + }) + }) + .then((response) => { + + if (!response.ok) + throw new Error(`Error loading site data for ID ${siteId}: ${response.statusText}`) + + return response + }) + .then(response => dispatch(siteDataSaveSuccess(siteId, siteObj))) + .catch(error => dispatch(siteDataSaveError(siteId, error))) + }; +} + +function siteDataIsSaving(siteId, isSaving) { + + return { + type: actionTypes.SITE_DATA_SAVE_SENDING, + payload: { siteId, isSaving } + } +} + +function siteDataSaveSuccess(siteId, siteObj) { + + return { + type: actionTypes.SITE_DATA_SAVE_RESPONSE, + payload: { siteId, siteObj }, + error: false + } +} + +// Normally the Payload should be an Error object for Flux Standard Actions. +// However for this case it's necessary to know the SiteID which failed within the dispatch. +// In case of an error the Payload will contain the Error object on a sub-key. +function siteDataSaveError(siteId, error) { + + console && console.log && console.log("Error fetching data for site data:", error) + + return { + type: actionTypes.SITE_DATA_SAVE_RESPONSE, + payload: { siteId, error }, + error: true + } +} diff --git a/app/js/app.js b/app/js/app.js new file mode 100644 index 0000000..88d5f83 --- /dev/null +++ b/app/js/app.js @@ -0,0 +1,12 @@ +import React from 'react' +import ReactDOM from 'react-dom' +import Application from './components/Application.jsx' +import { createStore, applyMiddleware } from 'redux' +import thunk from 'redux-thunk'; +import mainReducer from './reducers' +import logger from 'redux-logger' + + +let store = createStore(mainReducer, undefined, applyMiddleware(thunk, logger)) + +ReactDOM.render(, document.getElementById('app')); diff --git a/app/js/components/AddOrEditFlag.jsx b/app/js/components/AddOrEditFlag.jsx new file mode 100644 index 0000000..4768abe --- /dev/null +++ b/app/js/components/AddOrEditFlag.jsx @@ -0,0 +1,161 @@ +import { connect } from 'react-redux' +import React, { Component } from 'react'; +import { possibleFlagValues } from '../lib/constants' +import PropTypes from 'prop-types' + + +export default class AddOrEditFlag extends Component { + + constructor(props) { + + super(props); + + this.handleFormSubmit = this.handleFormSubmit.bind(this); + this.handleFlagTypeChange = this.handleFlagTypeChange.bind(this); + this.handleStartDateChange = this.handleStartDateChange.bind(this); + this.handleEndDateChange = this.handleEndDateChange.bind(this); + this.handleCancel = this.handleCancel.bind(this); + + this.state = { + flagType: this.props.defaultFlagType, + startDate: this.props.defaultStartDate, + endDate: this.props.defaultEndDate, + errorMessage: "" + } + } + + // The AddOrEditFlag should call this method with the Flag Object (to be inserted within the flags array) after it passes validation. + handleFormSubmit(event) { + + this.setState({errorMessage: ""}) + + let newErrorMessage = "" + let newStartTimestampUnix = 0; + let newEndTimestampUnix = 0; + + if(!this.state.flagType) + newErrorMessage = "You must select a Flag Type before submitting the form." + + if(this.state.startDate){ + + var possibleStartTimestamp = Date.parse(this.state.startDate) + + if(Number.isNaN(possibleStartTimestamp)) + newErrorMessage = "The start date is invalid." + else + newStartTimestampUnix = possibleStartTimestamp + } + + if(this.state.endDate){ + + var possibleEndTimestamp = Date.parse(this.state.endDate) + + if(Number.isNaN(possibleEndTimestamp)) + newErrorMessage = "The end date is invalid." + else + newEndTimestampUnix = possibleEndTimestamp + } + + if(newStartTimestampUnix && newEndTimestampUnix){ + + if(new Date(newStartTimestampUnix).toLocaleDateString() === new Date(newEndTimestampUnix).toLocaleDateString()) + newErrorMessage = "The start date may not be the same date as the end date." + + if(newStartTimestampUnix > newEndTimestampUnix) + newErrorMessage = "The start date must be before the end date." + } + + if(!newStartTimestampUnix && !newEndTimestampUnix) + newErrorMessage = "Either the start date or the end date must be provided." + + if(newErrorMessage) + this.setState({errorMessage: newErrorMessage}) + else + this.props.onSaveFlag({ + flagType: this.state.flagType, + startDate: this.state.startDate, + endDate: this.state.endDate + }) + + event.preventDefault() + } + + handleCancel(event) { + this.props.onClose() + } + + handleFlagTypeChange(event) { + this.setState({flagType: event.target.value}) + } + + handleStartDateChange(event) { + this.setState({startDate: event.target.value}) + } + + handleEndDateChange(event) { + this.setState({endDate: event.target.value}) + } + + componentDidMount() { + + } + + render() { + + let listMenuOptions = possibleFlagValues.map((flagType, index) => + + ) + + if(this.props.isAddForm) + listMenuOptions = [].concat(, listMenuOptions); + + const createOrUpdateText = this.props.isAddForm ? "Create New Flag" : "Update Flag" + + let errorMessageBlock = ""; + + if(this.state.errorMessage) + errorMessageBlock =
{this.state.errorMessage}
+ + + return ( + +
+
+ {this.props.isAddForm ? "Add a New Site Flag" : "Edit Site Flag"} + + {errorMessageBlock} + + + + + + +
+ + +
+
+
+ ); + } +} + +AddOrEditFlag.propTypes = { + onSaveFlag: PropTypes.func.isRequired, + onClose: PropTypes.func.isRequired, + defaultFlagType: PropTypes.string.isRequired, + defaultStartDate: PropTypes.string.isRequired, + defaultEndDate: PropTypes.string.isRequired, + isAddForm: PropTypes.bool.isRequired +} diff --git a/app/js/components/Application.jsx b/app/js/components/Application.jsx new file mode 100644 index 0000000..d8e8d1d --- /dev/null +++ b/app/js/components/Application.jsx @@ -0,0 +1,25 @@ +import { Switch, Route } from 'react-router-dom' +import React from 'react' +import PropTypes from 'prop-types' +import SiteListContainer from '../containers/SiteListContainer' +import SiteDetailsContainer from '../containers/SiteDetailsContainer' +import { BrowserRouter } from 'react-router-dom'; +import { Provider } from 'react-redux' + +const Application = ({ store }) => ( + + + + + + + + + +) + +Application.propTypes = { + store: PropTypes.object.isRequired +} + +export default Application diff --git a/app/js/components/FlagRow.jsx b/app/js/components/FlagRow.jsx new file mode 100644 index 0000000..a91fb11 --- /dev/null +++ b/app/js/components/FlagRow.jsx @@ -0,0 +1,27 @@ +import React, { Component } from 'react' +import { Link } from 'react-router-dom' +import PropTypes from 'prop-types' + + +function FlagRow (props) { + + return (
+
Flag Type: {props.flagType}
+
{props.startDate && "Start Date: "}{props.startDate}
+
{props.endDate && "End Date: "}{props.endDate}
+
+ + +
+
) +} + +FlagRow.propTypes = { + onShowEdit: PropTypes.func.isRequired, + onConfirmDelete: PropTypes.func.isRequired, + flagType: PropTypes.string.isRequired, + startDate: PropTypes.string.isRequired, + endDate: PropTypes.string.isRequired +} + +export default FlagRow; diff --git a/app/js/components/ModalConfirm.jsx b/app/js/components/ModalConfirm.jsx new file mode 100644 index 0000000..7575e85 --- /dev/null +++ b/app/js/components/ModalConfirm.jsx @@ -0,0 +1,45 @@ +import React, { Component } from 'react' +import { Link } from 'react-router-dom' +import PropTypes from 'prop-types' + + +function ModalConfirm (props) { + + const modalWrapperCss = { + position: "fixed", + zIndex: 2, + left: 0, + top: 0, + width: "100%", + height: "100%", + backgroundColor: "rgba(0,0,0,0.4)" + } + + const modalContent = { + backgroundColor: "#eee", + margin: "20% auto", + padding: "3em", + border: "2px dashed black", + width: "70%", + textAlign: "center", + borderRadius: "1em" + } + + return (
+
+
{props.message}
+
+ + +
+
+
) +} + +ModalConfirm.propTypes = { + onCancel: PropTypes.func.isRequired, + onConfirm: PropTypes.func.isRequired, + message: PropTypes.string.isRequired +} + +export default ModalConfirm; diff --git a/app/js/components/SiteDetails.jsx b/app/js/components/SiteDetails.jsx new file mode 100644 index 0000000..325f6a0 --- /dev/null +++ b/app/js/components/SiteDetails.jsx @@ -0,0 +1,88 @@ +import React, { Component } from 'react'; +import { Link } from 'react-router-dom' +import PropTypes from 'prop-types' +import AddFlagContainer from '../containers/AddFlagContainer' +import FlagRowContainer from '../containers/FlagRowContainer' + +function SiteDetails (props) { + + const siteObj = {} + + if(props.hasErrorLoading) + return ( +
There was an error loading the Site Details.
+ ) + + if(props.isLoading) + return ( +
... please wait ...
+ ) + + if(!props.isLoaded) + return ( +
The Site Details haven't been loaded from the server yet.
+ ) + + // Assign the original index to an object property before filtering the list. + // The array position is significant, it will be used as an identifier to a Container component below. + let filteredSiteFlagsByEndDate = props.siteFlags.map((flagObj, siteFlagIndex) => + Object.assign({}, flagObj, {siteFlagIndex}) + ) + + // Filter out any flags that have an EndDate, and which occurs in the past. + filteredSiteFlagsByEndDate = filteredSiteFlagsByEndDate.filter(flagObj => + (flagObj.endDate && Date.parse(flagObj.endDate) < Date.now()) ? false : true + ) + + const flagsTableRows = filteredSiteFlagsByEndDate.map((flagObj, flagIndex) => + + ) + + const isSavingTag =
... saving ...
+ const hasSavingErrorTag =
There was an error saving to the server.
+ + const commonHeader = ( +
+

{props.siteName}

+ Close Site Details +
+ { props.isSaving && isSavingTag } + { props.hasErrorSaving && hasSavingErrorTag } +
+ ) + + if(!props.siteFlags.length){ + + return ( +
+ {commonHeader} +
There are no site flags.
+
+ ) + } + else{ + + return ( +
+ {commonHeader} +
+ Active Flags: {filteredSiteFlagsByEndDate.length} - Expired: {props.siteFlags.length - filteredSiteFlagsByEndDate.length} +
+ {flagsTableRows} +
+ ) + } +} + +SiteDetails.propTypes = { + hasErrorLoading: PropTypes.bool.isRequired, + isLoading: PropTypes.bool.isRequired, + siteId: PropTypes.string.isRequired, + siteName: PropTypes.string.isRequired, + isLoaded: PropTypes.bool.isRequired, + siteFlags: PropTypes.array.isRequired, + hasErrorSaving: PropTypes.bool.isRequired, + isSaving: PropTypes.bool.isRequired +} + +export default SiteDetails \ No newline at end of file diff --git a/app/js/components/SiteList.jsx b/app/js/components/SiteList.jsx new file mode 100644 index 0000000..85c507e --- /dev/null +++ b/app/js/components/SiteList.jsx @@ -0,0 +1,63 @@ +import React, { Component } from 'react' +import { Link } from 'react-router-dom' +import PropTypes from 'prop-types' + + +function SiteList (props) { + + let { sites } = props + + if(props.sitesListLoadingError) + return ( +
There was an error loading the Site List.
+ ) + + if(props.sitesListIsLoading) + return ( +
... please wait ...
+ ) + + if(!sites.length) + return ( +
There are no available sites at this time.
+ ) + + return ( +
+ +
Site List
+ +
    + { + sites.map(siteObj => ( + +
  • + {siteObj.name} +
  • + )) + } +
+ +
+ ) +} + +SiteList.propTypes = { + + sitesListLoadingError: PropTypes.bool.isRequired, + sitesListIsLoading: PropTypes.bool.isRequired, + sites: PropTypes.array.isRequired, + + sites: PropTypes.arrayOf(function(propValue, key) { + + var validateMsgPrefix = "The SiteList component expects an array for the prop name 'sites'. Each element is an object. The element on key: " + key + " is expects an " + + if(!propValue[key]._id || typeof propValue[key]._id !== "string") + return new Error(validateMsgPrefix + " '_id' property of type string."); + + if(!propValue[key].name || typeof propValue[key].name !== "string") + return new Error(validateMsgPrefix + " 'name' property of type string."); + }) +} + +export default SiteList; diff --git a/app/js/containers/AddFlagContainer.jsx b/app/js/containers/AddFlagContainer.jsx new file mode 100644 index 0000000..cf388f0 --- /dev/null +++ b/app/js/containers/AddFlagContainer.jsx @@ -0,0 +1,75 @@ +import { connect } from 'react-redux' +import SiteList from '../components/SiteList' +import React, { Component } from 'react'; +import { saveSiteData } from '../actions/thunks' +import { addFlag, addFlagCancel } from '../actions/actionObjects' +import { getShowAddFlagFormForSelectedSite, getSelectedSiteId, getSelectedSiteObj } from '../selectors' +import AddOrEditFlag from '../components/AddOrEditFlag' +import PropTypes from 'prop-types' + + +class AddFlagContainer extends Component { + + constructor(props) { + + super(props); + + this.handleSaveFlag = this.handleSaveFlag.bind(this); + this.handleCloseAddForm = this.handleCloseAddForm.bind(this); + this.handleShowAddForm = this.handleShowAddForm.bind(this); + } + + // The AddOrEditFlag dummy component should call this method with the Flag Object (to be inserted within the SiteObject's flags array) after it passes validation. + handleSaveFlag(flagObj) { + + var existingFlagsArr = this.props.siteObj.flags.concat(); + + existingFlagsArr.push(flagObj); + + var updatedSiteObj = Object.assign({}, this.props.siteObj, { flags: existingFlagsArr }) + + // Get the existing Site Object and add just push the new Flag object onto its array. + // The REST call must update the entire SiteObj and everything in it. + this.props.dispatch(saveSiteData(this.props.siteId, updatedSiteObj)) + } + + handleCloseAddForm() { + this.props.dispatch(addFlagCancel(this.props.siteId)) + } + + handleShowAddForm() { + this.props.dispatch(addFlag(this.props.siteId)) + } + + render() { + + if(this.props.showAddFlagForm){ + + // Re-use the same component for Editing too. + return ( + ) + } + else{ + return + } + } +} + +AddFlagContainer.propTypes = { + siteId: PropTypes.string.isRequired, + showAddFlagForm: PropTypes.bool.isRequired +} + +export default connect(state => ( + { + siteId: getSelectedSiteId(state), + siteObj: getSelectedSiteObj(state), + showAddFlagForm: getShowAddFlagFormForSelectedSite(state) + } + ))(AddFlagContainer) \ No newline at end of file diff --git a/app/js/containers/FlagRowContainer.jsx b/app/js/containers/FlagRowContainer.jsx new file mode 100644 index 0000000..c551950 --- /dev/null +++ b/app/js/containers/FlagRowContainer.jsx @@ -0,0 +1,140 @@ +import { connect } from 'react-redux' +import SiteList from '../components/SiteList' +import React, { Component } from 'react'; +import { saveSiteData } from '../actions/thunks' +import { editFlag, editFlagCancel, confirmDeleteShow, confirmDeleteHide } from '../actions/actionObjects' +import AddOrEditFlag from '../components/AddOrEditFlag' +import ModalConfirm from '../components/ModalConfirm' +import FlagRow from '../components/FlagRow' +import PropTypes from 'prop-types' + +import {getShowAddFlagFormForSelectedSite, + getSelectedSiteId, + getSelectedSiteObj, + getFlagIndexBeingEditedForSelectedSite, + getFlagIndexWithDeleteModalForSelectedSite + } from '../selectors' + + +class FlagRowContainer extends Component { + + constructor(props) { + + super(props); + + this.handleUpdateFlag = this.handleUpdateFlag.bind(this); + this.handleCancelEdit = this.handleCancelEdit.bind(this); + this.handleShowEdit = this.handleShowEdit.bind(this); + this.handleConfirmDeleteShow = this.handleConfirmDeleteShow.bind(this); + this.handleConfirmDeleteHide = this.handleConfirmDeleteHide.bind(this); + this.handleDeleteFlagEntry = this.handleDeleteFlagEntry.bind(this); + + if(this.props.flagIndex >= this.props.siteObj.flags.length) + throw new Error("Error in FlagRowContainer constructor. The Flag Index is out of bounds."); + } + + handleUpdateFlag(flagObj) { + + let existingFlagsArr = this.props.siteObj.flags.concat(); + + existingFlagsArr[this.props.flagIndex] = flagObj; + + const updatedSiteObj = Object.assign({}, this.props.siteObj, { flags: existingFlagsArr }) + + // Get the existing Site Object and add just push the new Flag object onto its array. + // The REST call must update the entire SiteObj and everything in it. + this.props.dispatch(saveSiteData(this.props.siteId, updatedSiteObj)) + } + + handleDeleteFlagEntry() { + + if(this.props.flagIndexWithDeleteModal < 0) + throw new Error("A callback for a delete confirmation occured without a valid flagIndexWithDeleteModal prop."); + + // Make a copy of the array before deleting the entry. + let existingFlagsArr = this.props.siteObj.flags.concat(); + + existingFlagsArr.splice(this.props.flagIndexWithDeleteModal, 1) + + const updatedSiteObj = Object.assign({}, this.props.siteObj, { flags: existingFlagsArr }) + + this.props.dispatch(saveSiteData(this.props.siteId, updatedSiteObj)) + } + + handleCancelEdit() { + this.props.dispatch(editFlagCancel(this.props.siteId, this.props.flagIndex)) + } + + handleShowEdit() { + this.props.dispatch(editFlag(this.props.siteId, this.props.flagIndex)) + } + + handleConfirmDeleteShow() { + this.props.dispatch(confirmDeleteShow(this.props.siteId, this.props.flagIndex)) + } + + handleConfirmDeleteHide() { + this.props.dispatch(confirmDeleteHide(this.props.siteId, this.props.flagIndex)) + } + + render() { + + const currentFlagObj = this.props.siteObj.flags[this.props.flagIndex] + + let deleteModalComponent + + if(this.props.flagIndexWithDeleteModal === this.props.flagIndex) + deleteModalComponent = ( + ) + + const flagRowComponent = ( +
+ + {deleteModalComponent} +
+ ) + + // If these two match then it means that the given FlagRow is being edited. + if(this.props.flagIndexBeingEdited === this.props.flagIndex){ + + // Re-use the same component for Adding too. + return ( + + ) + } + else{ + return flagRowComponent + } + } +} + +FlagRowContainer.propTypes = { + siteId: PropTypes.string.isRequired, + siteObj: PropTypes.object.isRequired, + flagIndex: PropTypes.number.isRequired, + flagIndexBeingEdited: PropTypes.number.isRequired, + flagIndexWithDeleteModal: PropTypes.number.isRequired +} + +export default connect(state => ( + { + siteId: getSelectedSiteId(state), + siteObj: getSelectedSiteObj(state), + flagIndexBeingEdited: getFlagIndexBeingEditedForSelectedSite(state), + flagIndexWithDeleteModal: getFlagIndexWithDeleteModalForSelectedSite(state) + } + ))(FlagRowContainer) \ No newline at end of file diff --git a/app/js/containers/SiteDetailsContainer.jsx b/app/js/containers/SiteDetailsContainer.jsx new file mode 100644 index 0000000..675dbf3 --- /dev/null +++ b/app/js/containers/SiteDetailsContainer.jsx @@ -0,0 +1,88 @@ +import { connect } from 'react-redux' +import SiteDetails from '../components/SiteDetails' +import React, { Component } from 'react'; +import { fetchSiteData } from '../actions/thunks' +import { selectSite } from '../actions/actionObjects' +import PropTypes from 'prop-types' +import {getSelectedSiteId, + getSelectedSiteName, + getSelectedSiteFlags, + getSelectedSiteIsLoading, + getSelectedSiteIsSaving, + getSelectedSiteHasErrorLoading, + getSelectedSiteHasErrorSaving, + getSelectedSiteIsLoaded } from '../selectors' + + +class SiteDetailsContainer extends Component { + + componentDidMount() { + this.loadRoutine(); + } + + componentDidUpdate() { + this.loadRoutine(); + } + + loadRoutine() { + + let { dispatch, siteDetailsObj, isLoading, siteId, isLoaded } = this.props + + if(!this.props.match.params.id) + throw new Error("The 'id' must always be present within the SiteDetailsContainer because it's set by the Router."); + + // The SiteID is set by the Router and it should be saved to the Store whenever a change is detected. + if(siteId !== this.props.match.params.id){ + console.log("Selecting the Site ID on the State from the router:", this.props.match.params.id); + dispatch(selectSite(this.props.match.params.id)) + return; + } + + if(isLoading){ + console.log("The Site Details are still loading, once it's down the state will update and re-render this component."); + return; + } + + if(!isLoaded) + dispatch(fetchSiteData(siteId)) + } + + render() { + + const dumbChildProps = { + "siteId":this.props.siteId, + "siteName":this.props.siteName, + "isLoading":this.props.isLoading, + "siteFlags":this.props.siteFlags, + "isLoaded":this.props.isLoaded, + "hasErrorLoading":this.props.hasErrorLoading, + "hasErrorSaving":this.props.hasErrorSaving, + "isSaving":this.props.isSaving + } + + return + } +} + +SiteDetailsContainer.propTypes = { + hasErrorLoading: PropTypes.bool.isRequired, + isLoading: PropTypes.bool.isRequired, + siteId: PropTypes.string.isRequired, + siteName: PropTypes.string.isRequired, + isLoaded: PropTypes.bool.isRequired, + siteFlags: PropTypes.array.isRequired, + hasErrorSaving: PropTypes.bool.isRequired, + isSaving: PropTypes.bool.isRequired +} + +export default connect(state => ( + { + siteId: getSelectedSiteId(state), + siteName: getSelectedSiteName(state), + siteFlags: getSelectedSiteFlags(state), + isLoading: getSelectedSiteIsLoading(state), + isSaving: getSelectedSiteIsSaving(state), + isLoaded: getSelectedSiteIsLoaded(state), + hasErrorLoading: getSelectedSiteHasErrorLoading(state), + hasErrorSaving: getSelectedSiteHasErrorSaving(state) + }))(SiteDetailsContainer) \ No newline at end of file diff --git a/app/js/containers/SiteListContainer.jsx b/app/js/containers/SiteListContainer.jsx new file mode 100644 index 0000000..66d1a89 --- /dev/null +++ b/app/js/containers/SiteListContainer.jsx @@ -0,0 +1,53 @@ +import { connect } from 'react-redux' +import SiteList from '../components/SiteList' +import React, { Component } from 'react'; +import { fetchSitesList } from '../actions/thunks' +import { unselectActiveSite } from '../actions/actionObjects' +import { getSitesListIsLoading, getSitesListIsLoadingError, getSitesArr } from '../selectors' +import PropTypes from 'prop-types' + + +class SiteListContainer extends Component { + + componentDidMount() { + + const { dispatch, sites, sitesListIsLoading } = this.props + + dispatch(unselectActiveSite()); + + if(sitesListIsLoading){ + console.log("The Site List is already loading, no need to load it again."); + return; + } + + if(!sites.length) + dispatch(fetchSitesList()) + } + + render() { + + const dumbChildProps = { + "sitesListLoadingError":this.props.sitesListLoadingError, + "sitesListIsLoading":this.props.sitesListIsLoading, + "sites":this.props.sites + } + + return + } +} + +// The shape of the array objects will be validated within using a custom prop validator. +// It makes more sense to validate there because its the component which depends upon certain properties. +SiteListContainer.propTypes = { + sites: PropTypes.array.isRequired, + sitesListIsLoading: PropTypes.bool.isRequired, + sitesListLoadingError: PropTypes.bool.isRequired +} + +export default connect(state => ( + { + sites: getSitesArr(state), + sitesListIsLoading: getSitesListIsLoading(state), + sitesListLoadingError: getSitesListIsLoadingError(state) + } + ))(SiteListContainer) \ No newline at end of file diff --git a/app/js/lib/constants.js b/app/js/lib/constants.js new file mode 100644 index 0000000..4274125 --- /dev/null +++ b/app/js/lib/constants.js @@ -0,0 +1,15 @@ +// Ideally this would come from ENV variables. +const restBasePath = "http://localhost:3000" + +export const URL_SITES_LIST = restBasePath + "/sites" + +export const possibleFlagValues = [ + "Advertiser - Location Priority", + "Retailer - Location Priority", + "Retailer - Showcase", + "GSTV - Site Visit", + "GSTV - Showcase", + "GSTV - Nielsen Survey", + "GSTV - Research Survey", + "GSTV - Unsellable" + ] \ No newline at end of file diff --git a/app/js/reducers/index.js b/app/js/reducers/index.js new file mode 100644 index 0000000..cd0ba06 --- /dev/null +++ b/app/js/reducers/index.js @@ -0,0 +1,281 @@ +import { combineReducers } from 'redux' +import * as actionTypes from '../actions/actionTypes' +import cloneDeep from 'lodash.clonedeep' + +export default combineReducers({ + sitesArr, + sitesListDetails +}) + + +// This reducer handles communication for individual Sites and will take care of merging any Site-specific data into the array. +function sitesArr (state = [], action) { + + switch (action.type) { + + case actionTypes.SITE_DATA_SAVE_SENDING: { + + const siteArrCopy = state.concat(); + const siteIdFromPayload = action.payload.siteId + const siteObj = getSiteObjById(siteIdFromPayload, siteArrCopy) + + if(typeof action.payload.isSaving !== "boolean") + throw new Error("Error in reducer for SITE_DATA_SAVE_SENDING. action.payload.isSaving should be Boolean") + + siteObj.isSaving = action.payload.isSaving + + siteArrCopy[getSiteIndexWithinStateArrayById(siteIdFromPayload, siteArrCopy)] = siteObj + + return siteArrCopy + } + + case actionTypes.SITE_DATA_SAVE_RESPONSE: { + + const siteArrCopy = state.concat() + const siteIdFromPayload = action.payload.siteId + const siteObjectFromPayload = action.payload.siteObj + const indexOfSiteInArr = getSiteIndexWithinStateArrayById(siteIdFromPayload, siteArrCopy) + + if(action.error){ + + // Since there's an error, re-use the same Site object that's currently on the state (not the version sent to the server). + siteArrCopy[indexOfSiteInArr] = getSiteObjById(siteIdFromPayload, siteArrCopy) + + // If there's an error, leave the form(s) open so that the user can try again. + siteArrCopy[indexOfSiteInArr].hasErrorSaving = true + + // If there's an error saving to the server then the modal window needs to be closed in order to see the error message. + siteArrCopy[indexOfSiteInArr].showConfirmDeleteForFlagIndex = undefined + } + else{ + + // Since the update succeeded it is OK to add the new Site Object to the state. + siteArrCopy[indexOfSiteInArr] = cloneDeep(siteObjectFromPayload) + + // Make sure to close the form since the update succeeded. + // But don't take it down in case of an error or the user could lose data before re-submitting. + siteArrCopy[indexOfSiteInArr].showAddFlagForm = false + + // It's important to errase any of the indexes because if a Flag Entry was deleted before calling the Save API, the existing indexes could be out of bounds. + siteArrCopy[indexOfSiteInArr].showEditFormForFlagIndex = undefined + siteArrCopy[indexOfSiteInArr].showConfirmDeleteForFlagIndex = undefined + } + + // Whether an error happened or not, it's no longer saving. + siteArrCopy[indexOfSiteInArr].isSaving = false + + return siteArrCopy + } + + case actionTypes.UNSELECT_ACTIVE_SITE: { + + // Ran into a little problem here because it's not possible to quickly get the ID of the "selected Site" because of the ways that the reducers were separated. + // It wouldn't be efficient in production, but one solution is to loop through all of the array elements an reset any Open/Edit flags. + // The solution is either to normalize the data or add a Root Reducer. + // Fow now, if you start to Edit or Add a Flag on a Site, the form will remain opened when you return. + return state + } + + case actionTypes.SITES_LIST_RESPONSE: { + + if(action.error){ + return [] + } + else{ + + if(!Array.isArray(action.payload)) + throw new Error("Error in sitesArr reducer. The action payload must be of type array.") + + return action.payload.concat() + } + } + + case actionTypes.EDIT_FLAG: { + + const siteArrCopy = state.concat() + const siteIdFromPayload = action.payload.siteId + const siteObj = getSiteObjById(siteIdFromPayload, siteArrCopy) + + if(typeof action.payload.show !== "boolean") + throw new Error("Error in reducer for EDIT_FLAG. action.payload.show should be Boolean.") + + if(typeof action.payload.flagIndex !== "number") + throw new Error("Error in reducer for EDIT_FLAG. action.payload.flagIndex should be Number.") + + if(!siteObj.flags[action.payload.flagIndex]) + throw new Error("Error in reducer for EDIT_FLAG. The given flagIndex is out of bounds.") + + // If the Site Object stores a number on showEditFormForFlagIndex then it means that the Flag is in a state of being edited. + if(action.payload.show) + siteObj.showEditFormForFlagIndex = action.payload.flagIndex + else + siteObj.showEditFormForFlagIndex = undefined + + // If someone opens/closes an EDIT window then make sure to hide the ADD form. + siteObj.showAddFlagForm = false + + siteArrCopy[getSiteIndexWithinStateArrayById(siteIdFromPayload, siteArrCopy)] = siteObj + + return siteArrCopy + } + case actionTypes.CONFIRM_DELETE_FLAG: { + + const siteArrCopy = state.concat() + const siteIdFromPayload = action.payload.siteId + const siteObj = getSiteObjById(siteIdFromPayload, siteArrCopy) + + if(typeof action.payload.show !== "boolean") + throw new Error("Error in reducer for CONFIRM_DELETE_FLAG. action.payload.show should be Boolean.") + + if(typeof action.payload.flagIndex !== "number") + throw new Error("Error in reducer for CONFIRM_DELETE_FLAG. action.payload.flagIndex should be Number.") + + if(!siteObj.flags[action.payload.flagIndex]) + throw new Error("Error in reducer for CONFIRM_DELETE_FLAG. The given flagIndex is out of bounds.") + + // If the Site Object stores a number on showConfirmDeleteForFlagIndex then it means that the Flag has a Delete Confirm modal opened. + if(action.payload.show) + siteObj.showConfirmDeleteForFlagIndex = action.payload.flagIndex + else + siteObj.showConfirmDeleteForFlagIndex = undefined + + siteArrCopy[getSiteIndexWithinStateArrayById(siteIdFromPayload, siteArrCopy)] = siteObj + + return siteArrCopy + } + + case actionTypes.ADD_FLAG: { + + const siteArrCopy = state.concat() + const siteIdFromPayload = action.payload.siteId + const siteObj = getSiteObjById(siteIdFromPayload, siteArrCopy) + + if(typeof action.payload.show !== "boolean") + throw new Error("Error in reducer for ADD_FLAG. action.payload.show should be Boolean") + + siteObj.showAddFlagForm = action.payload.show + + // If someone opens/closes the ADD form on a Site Object, close any EDIT form. + siteObj.showEditFormForFlagIndex = undefined + + siteArrCopy[getSiteIndexWithinStateArrayById(siteIdFromPayload, siteArrCopy)] = siteObj + + return siteArrCopy + } + + case actionTypes.SITE_DATA_LOADING: { + + const siteArrCopy = state.concat() + const siteIdFromPayload = action.payload.siteId + const siteObj = getSiteObjById(siteIdFromPayload, siteArrCopy) + + if(typeof action.payload.isLoading !== "boolean") + throw new Error("Error in reducer for SITE_DATA_LOADING. action.payload.isLoading should be Boolean") + + siteObj.isLoading = action.payload.isLoading + + siteArrCopy[getSiteIndexWithinStateArrayById(siteIdFromPayload, siteArrCopy)] = siteObj + + return siteArrCopy + } + + case actionTypes.SITE_DATA_RESPONSE: { + + const siteArrCopy = state.concat() + const siteIdFromPayload = action.payload.siteId + const indexOfSiteInArr = getSiteIndexWithinStateArrayById(siteIdFromPayload, siteArrCopy) + + // If there's an error just use the existing record from the current state and set some flags on it. + if(action.error){ + + const existingSiteObjCopy = getSiteObjById(siteIdFromPayload, siteArrCopy) + + existingSiteObjCopy.isLoading = false + existingSiteObjCopy.isLoaded = false + existingSiteObjCopy.hasErrorLoading = true + + siteArrCopy[indexOfSiteInArr] = existingSiteObjCopy + + return siteArrCopy + } + + // Otherwise replace the existing Site object on the state array with the object returned from the REST call. + const siteObjFromPayload = action.payload.siteObj + + if(siteIdFromPayload !== siteObjFromPayload._id) + throw new Error("Error in SITE_DATA_RESPONSE reducer. The Payload Site ID does not match the _id key within the REST response.") + + siteObjFromPayload.isLoading = false + siteObjFromPayload.isLoaded = true + siteObjFromPayload.hasErrorLoading = false + + siteArrCopy[indexOfSiteInArr] = siteObjFromPayload + + return siteArrCopy + } + default: + return state + } +} + +// This reducer is just concerned about details of the list of Sites. +// For example, if the list is loading from the server, or which one of the ID's is currently selected. +function sitesListDetails ( + state = { + sitesListIsLoading:false, + sitesListLoadingError:false, + selectedSiteId:"" + }, + action) { + + switch (action.type) { + + case actionTypes.SITES_LIST_LOADING: + + if(typeof action.payload !== "boolean") + throw new Error("Error in reducer for SITES_LIST_LOADING. action.payload should be Boolean") + + return Object.assign({}, state, {sitesListIsLoading: action.payload }) + + case actionTypes.SELECT_SITE: + + if(action.payload.show) + return Object.assign({}, state, {selectedSiteId: action.payload.siteId }) + else + return Object.assign({}, state, {selectedSiteId: "" }) + + case actionTypes.UNSELECT_ACTIVE_SITE: + + return Object.assign({}, state, {selectedSiteId: "" }) + + case actionTypes.SITES_LIST_RESPONSE: + + if(action.error) + return Object.assign({}, state, {sitesListLoadingError: true, sitesListIsLoading: false }) + else + return Object.assign({}, state, {sitesListLoadingError: false, sitesListIsLoading: false }) + + default: + return state + } +} + + + +const getSiteIndexWithinStateArrayById = (siteId, sitesArr) => { + + const foundIndex = sitesArr.findIndex(siteObj => siteObj._id === siteId) + + if(foundIndex === -1) + throw new Error("Error in Reducer function getSiteIndexWithinStateArrayById. The given Site ID couldn't be found within the Site Array:" + siteId) + + return foundIndex +} + +const getSiteObjById = (siteId, sitesArr) => { + + const indexOfSite = getSiteIndexWithinStateArrayById(siteId, sitesArr) + + return cloneDeep(sitesArr[indexOfSite]) +} + diff --git a/app/js/selectors/index.js b/app/js/selectors/index.js new file mode 100644 index 0000000..628264a --- /dev/null +++ b/app/js/selectors/index.js @@ -0,0 +1,165 @@ +import { createSelector } from 'reselect' + + +export function getSitesArr(state){ + + if(!Array.isArray(state.sitesArr)) + throw new Error("Error in selector getSitesArr. The state object should contain an array for state.sitesArr[].") + + return state.sitesArr +} + +export function getSiteListDetails(state){ + + if(typeof state.sitesListDetails !== "object") + throw new Error("Error in selector getSiteListDetails. The sitesListDetails should be of type object.") + + return state.sitesListDetails +} + +export const getSelectedSiteId = createSelector( + getSiteListDetails, + getSiteListDetailsObj => getSiteListDetailsObj.selectedSiteId +) + +export const getSitesListIsLoading = createSelector( + getSiteListDetails, + getSiteListDetailsObj => getSiteListDetailsObj.sitesListIsLoading +) + +export const getSitesListIsLoadingError = createSelector( + getSiteListDetails, + getSiteListDetailsObj => getSiteListDetailsObj.sitesListLoadingError +) + +export const getSelectedSiteObj = createSelector( + getSelectedSiteId, + getSitesArr, + (selectedSiteId, sitesArr) => { + + let filteredSitesArr = sitesArr.filter(siteObj => siteObj._id === selectedSiteId) + + if(filteredSitesArr.length !== 1) + return null + + return filteredSitesArr[0] + } +) + +export const getSelectedSiteIsLoading = createSelector( + getSelectedSiteObj, + selectedSiteIdObj => { + + if(!selectedSiteIdObj) + return false + + return selectedSiteIdObj.isLoading ? true : false + } +) + +export const getSelectedSiteIsLoaded = createSelector( + getSelectedSiteObj, + selectedSiteIdObj => { + + if(!selectedSiteIdObj) + return false + + return selectedSiteIdObj.isLoaded ? true : false + } +) + +export const getSelectedSiteHasErrorLoading = createSelector( + getSelectedSiteObj, + selectedSiteIdObj => { + + if(!selectedSiteIdObj) + return false + + return selectedSiteIdObj.hasErrorLoading ? true : false + } +) + +export const getSelectedSiteHasErrorSaving = createSelector( + getSelectedSiteObj, + selectedSiteIdObj => { + + if(!selectedSiteIdObj) + return false + + return selectedSiteIdObj.hasErrorSaving ? true : false + } +) + +export const getSelectedSiteIsSaving = createSelector( + getSelectedSiteObj, + selectedSiteIdObj => { + + if(!selectedSiteIdObj) + return false + + return selectedSiteIdObj.isSaving ? true : false + } +) + +export const getSelectedSiteFlags = createSelector( + getSelectedSiteObj, + selectedSiteIdObj => { + + if(!selectedSiteIdObj || !selectedSiteIdObj.flags) + return [] + + return selectedSiteIdObj.flags + } +) + +export const getSelectedSiteName = createSelector( + getSelectedSiteObj, + selectedSiteIdObj => { + + if(!selectedSiteIdObj) + return ""; + + return selectedSiteIdObj.name + } +) + +export const getShowAddFlagFormForSelectedSite = createSelector( + getSelectedSiteObj, + selectedSiteIdObj => { + + if(!selectedSiteIdObj) + return false + + return selectedSiteIdObj.showAddFlagForm ? true : false + } +) + +// Returns -1 if the selected Site Object has no flags being edited, otherwise returns zero-based index corresponding to the position in the array being edited. +export const getFlagIndexBeingEditedForSelectedSite = createSelector( + getSelectedSiteObj, + selectedSiteIdObj => { + + if(!selectedSiteIdObj || typeof selectedSiteIdObj.showEditFormForFlagIndex === "undefined") + return -1 + + if(typeof selectedSiteIdObj.showEditFormForFlagIndex !== "number" || !selectedSiteIdObj.flags[selectedSiteIdObj.showEditFormForFlagIndex]) + throw new Error("Error in getFlagIndexBeingEditedForSelectedSite. The Flag Index selected for editing is out of bounds.") + + return selectedSiteIdObj.showEditFormForFlagIndex + } +) + +// Returns -1 if the selected Site Object does not have a Delete Modal open. Otherwise the value corresponds to the Flag index which has the modal visible. +export const getFlagIndexWithDeleteModalForSelectedSite = createSelector( + getSelectedSiteObj, + selectedSiteIdObj => { + + if(!selectedSiteIdObj || typeof selectedSiteIdObj.showConfirmDeleteForFlagIndex === "undefined") + return -1 + + if(typeof selectedSiteIdObj.showConfirmDeleteForFlagIndex !== "number" || !selectedSiteIdObj.flags[selectedSiteIdObj.showConfirmDeleteForFlagIndex]) + throw new Error("Error in getFlagIndexWithDeleteModalForSelectedSite. The Flag Index selected for editing is out of bounds.") + + return selectedSiteIdObj.showConfirmDeleteForFlagIndex + } +) diff --git a/app/test/ModalConfirm.spec.js b/app/test/ModalConfirm.spec.js new file mode 100644 index 0000000..95dbb8a --- /dev/null +++ b/app/test/ModalConfirm.spec.js @@ -0,0 +1,41 @@ +import React from 'react'; +import { mount, shallow } from 'enzyme'; +import {expect} from 'chai'; +import sinon from 'sinon'; + +import ModalConfirm from '../js/components/ModalConfirm'; + +describe('', () => { + + let wrapper + + let onConfirm + let onCancel + + beforeEach(() => { + onConfirm = sinon.spy() + onCancel = sinon.spy() + + wrapper = mount() + }) + + it('Modal renders Confirm and Cancel buttons', () => { + expect(wrapper.find('.button--confirm').length).to.equal(1) + expect(wrapper.find('.button--cancel').length).to.equal(1) + }) + + it('Modal Confirm Button Click should invoke onConfirm callback in props', () => { + wrapper.find('.button--confirm').at(0).simulate('click') + expect(onConfirm.calledOnce).to.equal(true); + }) + + it('Modal Cancel Button Click should invoke onCancel callback in props', () => { + wrapper.find('.button--cancel').at(0).simulate('click') + expect(onCancel.calledOnce).to.equal(true); + }) + + it('Modal Window should contain given text within message prop.', () => { + expect(wrapper.find('.container--message').first().text()).to.equal('Testing Message'); + }) + +}); diff --git a/app/test/browser.js b/app/test/browser.js new file mode 100644 index 0000000..649d211 --- /dev/null +++ b/app/test/browser.js @@ -0,0 +1,23 @@ +require('babel-core/register')(); + +var jsdom = require('jsdom'); +const { JSDOM } = jsdom; + +var exposedProperties = ['window', 'navigator', 'document']; + +const { document } = (new JSDOM('')).window; +global.document = document; + +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; \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..2cb363c --- /dev/null +++ b/package.json @@ -0,0 +1,66 @@ +{ + "name": "full-stack-coding-exercise", + "version": "1.0.0", + "description": "GSTV Coding Excercise", + "main": "index.js", + "scripts": { + "test": "mocha app/test/browser.js app/**/*.spec.js", + "dev": "node ./node_modules/webpack-dev-server/bin/webpack-dev-server.js --hot --inline --history-api-fallback" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/BrianPiere/full-stack-coding-exercise.git" + }, + "keywords": [ + "GSTV" + ], + "author": "Brian Piere", + "license": "ISC", + "bugs": { + "url": "https://github.com/BrianPiere/full-stack-coding-exercise/issues" + }, + "homepage": "https://github.com/BrianPiere/full-stack-coding-exercise#readme", + "dependencies": { + "enzyme": "^2.9.1", + "es6-promise": "^4.1.1", + "express": "^4.15.4", + "fetch-everywhere": "^1.0.5", + "html-webpack-plugin": "^2.30.1", + "lodash.clonedeep": "^4.5.0", + "mongodb": "^2.2.31", + "prop-types": "^15.5.10", + "react": "^15.6.1", + "react-dom": "^15.6.1", + "react-redux": "^5.0.6", + "react-router-dom": "^4.2.2", + "redux": "^3.7.2", + "redux-logger": "^3.0.6", + "redux-thunk": "^2.2.0", + "reselect": "^3.0.1" + }, + "devDependencies": { + "babel-core": "^6.26.0", + "babel-loader": "^7.1.2", + "babel-preset-es2015": "^6.24.1", + "babel-preset-react": "^6.24.1", + "chai": "^4.1.2", + "enzyme": "^2.9.1", + "eslint": "^4.3.0", + "eslint-config-airbnb": "^15.1.0", + "eslint-plugin-import": "^2.7.0", + "eslint-plugin-jsx-a11y": "^5.1.1", + "eslint-plugin-react": "^7.1.0", + "file-loader": "^0.11.2", + "jest": "^21.0.2", + "jsdom": "11.2.0", + "jsdom-global": "3.0.2", + "mocha": "^3.5.0", + "react-addons-test-utils": "^15.6.0", + "react-dom": "^15.6.1", + "react-hot-loader": "^1.3.1", + "react-test-renderer": "^15.6.1", + "sinon": "^3.2.1", + "webpack": "^3.5.5", + "webpack-dev-server": "^2.7.1" + } +} diff --git a/server/dbconnection b/server/dbconnection new file mode 100644 index 0000000..0e626cf --- /dev/null +++ b/server/dbconnection @@ -0,0 +1 @@ +mongodb://fullstack:PwForFullStackExcersise@ds159033.mlab.com:59033/fullstackexcersize \ No newline at end of file diff --git a/server/rest.js b/server/rest.js new file mode 100644 index 0000000..9660037 --- /dev/null +++ b/server/rest.js @@ -0,0 +1,151 @@ +"use strict" + +const express = require('express') +const fs = require('fs') +const app = express() +const MongoClient = require('mongodb').MongoClient +const MongoObjectId = require('mongodb').ObjectID +const bodyParser = require('body-parser') + + +app.use(bodyParser.text()); + +app.all('/*', function(req, res, next) { + res.header("Access-Control-Allow-Origin", "*") + res.header('Access-Control-Allow-Methods', 'GET,PUT,POST,OPTIONS,HEAD') + res.header("Access-Control-Allow-Headers", "X-Requested-With") + next(); +}); + +let db + +// Sensitive data like this should come from ENV variables in a real-world application. +let dbConnectionStr = fs.readFileSync(__dirname+'/dbconnection', 'utf8') + +// Don't start the REST server until verifying the DB connection. +MongoClient.connect(dbConnectionStr, (err, database) => { + + if (err) return console.log(err) + + db = database + + app.listen(3000, () => { + console.log('listening on 3000') + }) +}) + +app.get('/', function (req, res) { + res.send("Look Maw, I'm on GSTV") +}) + +// Returns a list of all Site Names and ID's +app.get('/sites', function (req, res) { + + // Only pull out the ID and the name for the list of sites. + // Let a sub-path download the full "site" document. + db.collection('sites').find({}, {_id:1, name:1}).toArray((err, result) => { + + if (err) { + console.log("Unable query the sites Collection", err) + res.send({error: "Database error."}); + return; + } + + res.send(result); + }) +}) + +// Returns a single Site document with any embedded flags. +app.get('/sites/:id', function (req, res) { + + let siteId = MongoObjectId(req.params.id); + + db.collection('sites').findOne({_id: siteId}, (err, result) => { + + if (err) { + console.log(`Unable get the Site document for ID: ${siteId}`, err) + res.send({error: "Database error."}); + return; + } + + if(!result){ + res.status(404); + res.send({error: "Cannot find a matching document."}); + return; + } + + res.send(result); + }) +}) + +// Updates the Site ID with the given Site document. +app.post('/sites/:id', function (req, res) { + + let siteId = MongoObjectId(req.params.id); + let siteDocument + + try{ + siteDocument = JSON.parse(req.body) + } + catch(e){ + console.log(`Unable parse the JSON Site document for updating: ${siteId}`, e) + res.send({error: "Unable to parse JSON body."}); + return; + } + + // Ideally Mongoose should be used to validate the Site Document schema, including all of the possible attributes within the 'flags' array. + if(!Array.isArray(siteDocument.flags)) + return res.send({error: "Cannot update the Site document. The 'flags' property must be of type array."}); + + // This API shouldn't let callers modify the Site name or _id. + let flagsPropertyOnly = Object.assign({}, {"flags":siteDocument.flags}) + + db.collection('sites').updateOne({_id: siteId}, {$set:flagsPropertyOnly}, {safe:true}, (err, result) => { + + if (err) { + console.log(`Unable update the Site document for ID: ${siteId}`, err) + res.send({error: "Database error."}); + return; + } + + if(!result.result || result.result.n !== 1){ + console.log(`Unable update the Site document for ID: ${siteId}. Mongo returned with an invalid result: ${result}`) + res.status(404); + res.send({error: "The update operation failed. The ID is likely invalid."}); + return; + } + + res.send("OK"); + }) +}) + +app.get('/populate_sites_collection', (req, res) => { + + let disabled = true; + let numberOfDefaultRecords = 24; + + if(disabled){ + res.send('This route has been disabled to prevent accidental data loss. It will drop the collection and insert default records when enabled.') + return; + } + + let initialSiteCollection = []; + + for(let i=1; i<=numberOfDefaultRecords; i++) + initialSiteCollection.push( { name: "Site Name "+i, flags:[] } ) + + db.collection('sites').remove({}, (err, result) => { + + if (err) return console.log("Unable to Drop Collection", err) + + db.collection('sites').insertMany(initialSiteCollection, (err, result) => { + + if (err) return console.log("Unable to insert collection", err) + + console.log('Sites Collection saved to MongoDB') + + res.send('Collection Saved') + }) + }) + +}) \ No newline at end of file diff --git a/webpack.config.js b/webpack.config.js new file mode 100644 index 0000000..924fb89 --- /dev/null +++ b/webpack.config.js @@ -0,0 +1,42 @@ +const path = require('path'); + +var HtmlWebpackPlugin = require('html-webpack-plugin'); +var webpack = require('webpack'); + +var HTMLWebpackPluginConfig = new HtmlWebpackPlugin({ + template: __dirname + '/app/index.html', + filename: 'index.html', + inject: false +}) + +var PollyFillWebPackPlugin = new webpack.ProvidePlugin({ + 'Promise': 'es6-promise' +}) + +module.exports = { + + context: __dirname + "/app", + + entry: { + javascript: "./js/app.js" + }, + + output: { + filename: "app.js", + path: __dirname + "/dist", + }, + + resolve: { + extensions: ['.js', '.jsx', '.json'] + }, + + module: { + loaders: [ + { test: /\.js$/, loader: 'babel-loader', exclude: /node_modules/ }, + { test: /\.jsx$/, loader: ['react-hot-loader', 'babel-loader'], exclude: /node_modules/ }, + ] + }, + + plugins: [HTMLWebpackPluginConfig, PollyFillWebPackPlugin] + +}; \ No newline at end of file