diff --git a/client/.gitignore b/client/.gitignore new file mode 100644 index 0000000..2a95a0e --- /dev/null +++ b/client/.gitignore @@ -0,0 +1,10 @@ +# Node.JS Specific +dist/ +node_modules/ +package-lock.json + +# VSCode +.vscode/ + +# MacOS +.DS_Store diff --git a/client/LICENSE b/client/LICENSE new file mode 100644 index 0000000..edacd8e --- /dev/null +++ b/client/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2018 Valentino Gagliardi + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/client/index.html b/client/index.html new file mode 100644 index 0000000..027d140 --- /dev/null +++ b/client/index.html @@ -0,0 +1,35 @@ + + + + + + + + + + + + + Cars + + + +
+ + + + + + + + + + + + + + \ No newline at end of file diff --git a/client/package.json b/client/package.json new file mode 100644 index 0000000..b56ec4c --- /dev/null +++ b/client/package.json @@ -0,0 +1,34 @@ +{ + "name": "reactreduxapp", + "version": "1.0.0", + "description": "React Redux App", + "repository": "git://github.com", + "main": "./src/index.js", + "scripts": { + "start": "webpack --watch --mode=development & webpack-dev-server --hot --mode=development", + "build": "webpack --mode=production" + }, + "author": "YJ", + "license": "MIT", + "dependencies": { + "@types/react": "^16.3.17", + "@types/react-dom": "^16.0.6", + "@types/react-redux": "^6.0.2", + "react": "^16.4.1", + "react-dom": "^16.4.1", + "react-redux": "^5.0.7", + "redux": "^4.0.0", + "request": "^2.87.0" + }, + "devDependencies": { + "@types/request": "^2.47.1", + "@types/uuid": "^3.4.3", + "awesome-typescript-loader": "^5.0.0", + "json-loader": "^0.5.7", + "source-map-loader": "^0.2.3", + "typescript": "^2.9.2", + "webpack": "^4.12.0", + "webpack-cli": "^3.0.7", + "webpack-dev-server": "^3.1.4" + } +} diff --git a/client/src/assets/css/fa-svg-with-js.css b/client/src/assets/css/fa-svg-with-js.css new file mode 100644 index 0000000..264ae48 --- /dev/null +++ b/client/src/assets/css/fa-svg-with-js.css @@ -0,0 +1,345 @@ +/*! + * Font Awesome Free 5.0.13 by @fontawesome - https://fontawesome.com + * License - https://fontawesome.com/license (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) + */ +svg:not(:root).svg-inline--fa { + overflow: visible; } + +.svg-inline--fa { + display: inline-block; + font-size: inherit; + height: 1em; + overflow: visible; + vertical-align: -.125em; } + .svg-inline--fa.fa-lg { + vertical-align: -.225em; } + .svg-inline--fa.fa-w-1 { + width: 0.0625em; } + .svg-inline--fa.fa-w-2 { + width: 0.125em; } + .svg-inline--fa.fa-w-3 { + width: 0.1875em; } + .svg-inline--fa.fa-w-4 { + width: 0.25em; } + .svg-inline--fa.fa-w-5 { + width: 0.3125em; } + .svg-inline--fa.fa-w-6 { + width: 0.375em; } + .svg-inline--fa.fa-w-7 { + width: 0.4375em; } + .svg-inline--fa.fa-w-8 { + width: 0.5em; } + .svg-inline--fa.fa-w-9 { + width: 0.5625em; } + .svg-inline--fa.fa-w-10 { + width: 0.625em; } + .svg-inline--fa.fa-w-11 { + width: 0.6875em; } + .svg-inline--fa.fa-w-12 { + width: 0.75em; } + .svg-inline--fa.fa-w-13 { + width: 0.8125em; } + .svg-inline--fa.fa-w-14 { + width: 0.875em; } + .svg-inline--fa.fa-w-15 { + width: 0.9375em; } + .svg-inline--fa.fa-w-16 { + width: 1em; } + .svg-inline--fa.fa-w-17 { + width: 1.0625em; } + .svg-inline--fa.fa-w-18 { + width: 1.125em; } + .svg-inline--fa.fa-w-19 { + width: 1.1875em; } + .svg-inline--fa.fa-w-20 { + width: 1.25em; } + .svg-inline--fa.fa-pull-left { + margin-right: .3em; + width: auto; } + .svg-inline--fa.fa-pull-right { + margin-left: .3em; + width: auto; } + .svg-inline--fa.fa-border { + height: 1.5em; } + .svg-inline--fa.fa-li { + width: 2em; } + .svg-inline--fa.fa-fw { + width: 1.25em; } + +.fa-layers svg.svg-inline--fa { + bottom: 0; + left: 0; + margin: auto; + position: absolute; + right: 0; + top: 0; } + +.fa-layers { + display: inline-block; + height: 1em; + position: relative; + text-align: center; + vertical-align: -.125em; + width: 1em; } + .fa-layers svg.svg-inline--fa { + -webkit-transform-origin: center center; + transform-origin: center center; } + +.fa-layers-text, .fa-layers-counter { + display: inline-block; + position: absolute; + text-align: center; } + +.fa-layers-text { + left: 50%; + top: 50%; + -webkit-transform: translate(-50%, -50%); + transform: translate(-50%, -50%); + -webkit-transform-origin: center center; + transform-origin: center center; } + +.fa-layers-counter { + background-color: #ff253a; + border-radius: 1em; + -webkit-box-sizing: border-box; + box-sizing: border-box; + color: #fff; + height: 1.5em; + line-height: 1; + max-width: 5em; + min-width: 1.5em; + overflow: hidden; + padding: .25em; + right: 0; + text-overflow: ellipsis; + top: 0; + -webkit-transform: scale(0.25); + transform: scale(0.25); + -webkit-transform-origin: top right; + transform-origin: top right; } + +.fa-layers-bottom-right { + bottom: 0; + right: 0; + top: auto; + -webkit-transform: scale(0.25); + transform: scale(0.25); + -webkit-transform-origin: bottom right; + transform-origin: bottom right; } + +.fa-layers-bottom-left { + bottom: 0; + left: 0; + right: auto; + top: auto; + -webkit-transform: scale(0.25); + transform: scale(0.25); + -webkit-transform-origin: bottom left; + transform-origin: bottom left; } + +.fa-layers-top-right { + right: 0; + top: 0; + -webkit-transform: scale(0.25); + transform: scale(0.25); + -webkit-transform-origin: top right; + transform-origin: top right; } + +.fa-layers-top-left { + left: 0; + right: auto; + top: 0; + -webkit-transform: scale(0.25); + transform: scale(0.25); + -webkit-transform-origin: top left; + transform-origin: top left; } + +.fa-lg { + font-size: 1.33333em; + line-height: 0.75em; + vertical-align: -.0667em; } + +.fa-xs { + font-size: .75em; } + +.fa-sm { + font-size: .875em; } + +.fa-1x { + font-size: 1em; } + +.fa-2x { + font-size: 2em; } + +.fa-3x { + font-size: 3em; } + +.fa-4x { + font-size: 4em; } + +.fa-5x { + font-size: 5em; } + +.fa-6x { + font-size: 6em; } + +.fa-7x { + font-size: 7em; } + +.fa-8x { + font-size: 8em; } + +.fa-9x { + font-size: 9em; } + +.fa-10x { + font-size: 10em; } + +.fa-fw { + text-align: center; + width: 1.25em; } + +.fa-ul { + list-style-type: none; + margin-left: 2.5em; + padding-left: 0; } + .fa-ul > li { + position: relative; } + +.fa-li { + left: -2em; + position: absolute; + text-align: center; + width: 2em; + line-height: inherit; } + +.fa-border { + border: solid 0.08em #eee; + border-radius: .1em; + padding: .2em .25em .15em; } + +.fa-pull-left { + float: left; } + +.fa-pull-right { + float: right; } + +.fa.fa-pull-left, +.fas.fa-pull-left, +.far.fa-pull-left, +.fal.fa-pull-left, +.fab.fa-pull-left { + margin-right: .3em; } + +.fa.fa-pull-right, +.fas.fa-pull-right, +.far.fa-pull-right, +.fal.fa-pull-right, +.fab.fa-pull-right { + margin-left: .3em; } + +.fa-spin { + -webkit-animation: fa-spin 2s infinite linear; + animation: fa-spin 2s infinite linear; } + +.fa-pulse { + -webkit-animation: fa-spin 1s infinite steps(8); + animation: fa-spin 1s infinite steps(8); } + +@-webkit-keyframes fa-spin { + 0% { + -webkit-transform: rotate(0deg); + transform: rotate(0deg); } + 100% { + -webkit-transform: rotate(360deg); + transform: rotate(360deg); } } + +@keyframes fa-spin { + 0% { + -webkit-transform: rotate(0deg); + transform: rotate(0deg); } + 100% { + -webkit-transform: rotate(360deg); + transform: rotate(360deg); } } + +.fa-rotate-90 { + -ms-filter: "progid:DXImageTransform.Microsoft.BasicImage(rotation=1)"; + -webkit-transform: rotate(90deg); + transform: rotate(90deg); } + +.fa-rotate-180 { + -ms-filter: "progid:DXImageTransform.Microsoft.BasicImage(rotation=2)"; + -webkit-transform: rotate(180deg); + transform: rotate(180deg); } + +.fa-rotate-270 { + -ms-filter: "progid:DXImageTransform.Microsoft.BasicImage(rotation=3)"; + -webkit-transform: rotate(270deg); + transform: rotate(270deg); } + +.fa-flip-horizontal { + -ms-filter: "progid:DXImageTransform.Microsoft.BasicImage(rotation=0, mirror=1)"; + -webkit-transform: scale(-1, 1); + transform: scale(-1, 1); } + +.fa-flip-vertical { + -ms-filter: "progid:DXImageTransform.Microsoft.BasicImage(rotation=2, mirror=1)"; + -webkit-transform: scale(1, -1); + transform: scale(1, -1); } + +.fa-flip-horizontal.fa-flip-vertical { + -ms-filter: "progid:DXImageTransform.Microsoft.BasicImage(rotation=2, mirror=1)"; + -webkit-transform: scale(-1, -1); + transform: scale(-1, -1); } + +:root .fa-rotate-90, +:root .fa-rotate-180, +:root .fa-rotate-270, +:root .fa-flip-horizontal, +:root .fa-flip-vertical { + -webkit-filter: none; + filter: none; } + +.fa-stack { + display: inline-block; + height: 2em; + position: relative; + width: 2em; } + +.fa-stack-1x, +.fa-stack-2x { + bottom: 0; + left: 0; + margin: auto; + position: absolute; + right: 0; + top: 0; } + +.svg-inline--fa.fa-stack-1x { + height: 1em; + width: 1em; } + +.svg-inline--fa.fa-stack-2x { + height: 2em; + width: 2em; } + +.fa-inverse { + color: #fff; } + +.sr-only { + border: 0; + clip: rect(0, 0, 0, 0); + height: 1px; + margin: -1px; + overflow: hidden; + padding: 0; + position: absolute; + width: 1px; } + +.sr-only-focusable:active, .sr-only-focusable:focus { + clip: auto; + height: auto; + margin: 0; + overflow: visible; + position: static; + width: auto; } diff --git a/client/src/assets/js/fontawesome-all.min.js b/client/src/assets/js/fontawesome-all.min.js new file mode 100644 index 0000000..a382743 --- /dev/null +++ b/client/src/assets/js/fontawesome-all.min.js @@ -0,0 +1,5 @@ +/*! + * Font Awesome Free 5.0.13 by @fontawesome - https://fontawesome.com + * License - https://fontawesome.com/license (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) + */ +!function(){"use strict";var c={};try{"undefined"!=typeof window&&(c=window)}catch(c){}var l=(c.navigator||{}).userAgent,h=void 0===l?"":l,v=c,z=(~h.indexOf("MSIE")||h.indexOf("Trident/"),"___FONT_AWESOME___"),e=function(){try{return!0}catch(c){return!1}}(),m=[1,2,3,4,5,6,7,8,9,10],a=m.concat([11,12,13,14,15,16,17,18,19,20]);["xs","sm","lg","fw","ul","li","border","pull-left","pull-right","spin","pulse","rotate-90","rotate-180","rotate-270","flip-horizontal","flip-vertical","stack","stack-1x","stack-2x","inverse","layers","layers-text","layers-counter"].concat(m.map(function(c){return c+"x"})).concat(a.map(function(c){return"w-"+c}));var s=v||{};s[z]||(s[z]={}),s[z].styles||(s[z].styles={}),s[z].hooks||(s[z].hooks={}),s[z].shims||(s[z].shims=[]);var t=s[z],f=Object.assign||function(c){for(var l=1;l>>0;h--;)l[h]=c[h];return l}function X(c){return c.classList?D(c.classList):(c.getAttribute("class")||"").split(" ").filter(function(c){return c})}function Y(c,l){var h,v=l.split("-"),z=v[0],e=v.slice(1).join("-");return z!==c||""===e||(h=e,~d.indexOf(h))?null:e}function U(c){return(""+c).replace(/&/g,"&").replace(/"/g,""").replace(/'/g,"'").replace(//g,">")}function K(h){return Object.keys(h||{}).reduce(function(c,l){return c+(l+": ")+h[l]+";"},"")}function G(c){return c.size!==I.size||c.x!==I.x||c.y!==I.y||c.rotate!==I.rotate||c.flipX||c.flipY}function J(c){var l=c.transform,h=c.containerWidth,v=c.iconWidth;return{outer:{transform:"translate("+h/2+" 256)"},inner:{transform:"translate("+32*l.x+", "+32*l.y+") "+" "+("scale("+l.size/16*(l.flipX?-1:1)+", "+l.size/16*(l.flipY?-1:1)+") ")+" "+("rotate("+l.rotate+" 0 0)")},path:{transform:"translate("+v/2*-1+" -256)"}}}var Q={x:0,y:0,width:"100%",height:"100%"},Z=function(c){var l=c.children,h=c.attributes,v=c.main,z=c.mask,e=c.transform,m=v.width,a=v.icon,s=z.width,t=z.icon,f=J({transform:e,containerWidth:s,iconWidth:m}),M={tag:"rect",attributes:S({},Q,{fill:"white"})},r={tag:"g",attributes:S({},f.inner),children:[{tag:"path",attributes:S({},a.attributes,f.path,{fill:"black"})}]},H={tag:"g",attributes:S({},f.outer),children:[r]},i="mask-"+B(),n="clip-"+B(),V={tag:"defs",children:[{tag:"clipPath",attributes:{id:n},children:[t]},{tag:"mask",attributes:S({},Q,{id:i,maskUnits:"userSpaceOnUse",maskContentUnits:"userSpaceOnUse"}),children:[M,H]}]};return l.push(V,{tag:"rect",attributes:S({fill:"currentColor","clip-path":"url(#"+n+")",mask:"url(#"+i+")"},Q)}),{children:l,attributes:h}},$=function(c){var l=c.children,h=c.attributes,v=c.main,z=c.transform,e=K(c.styles);if(0"+m.map(uc).join("")+""}var dc=function(){};function pc(c){return"string"==typeof(c.getAttribute?c.getAttribute(g):null)}var bc={replace:function(c){var l=c[0],h=c[1].map(function(c){return uc(c)}).join("\n");if(l.parentNode&&l.outerHTML)l.outerHTML=h+(O.keepOriginalSource&&"svg"!==l.tagName.toLowerCase()?"\x3c!-- "+l.outerHTML+" --\x3e":"");else if(l.parentNode){var v=document.createElement("span");l.parentNode.replaceChild(v,l),v.outerHTML=h}},nest:function(c){var l=c[0],h=c[1];if(~X(l).indexOf(O.replacementClass))return bc.replace(c);var v=new RegExp(O.familyPrefix+"-.*");delete h[0].attributes.style;var z=h[0].attributes.class.split(" ").reduce(function(c,l){return l===O.replacementClass||l.match(v)?c.toSvg.push(l):c.toNode.push(l),c},{toNode:[],toSvg:[]});h[0].attributes.class=z.toSvg.join(" ");var e=h.map(function(c){return uc(c)}).join("\n");l.setAttribute("class",z.toNode.join(" ")),l.setAttribute(g,""),l.innerHTML=e}};function gc(h,c){var v="function"==typeof c?c:dc;0===h.length?v():(a.requestAnimationFrame||function(c){return c()})(function(){var c=!0===O.autoReplaceSvg?bc.replace:bc[O.autoReplaceSvg]||bc.replace,l=sc.begin("mutate");h.map(c),l(),v()})}var wc=!1;var yc=null;var Sc=function(c){var l=c.getAttribute("style"),h=[];return l&&(h=l.split(";").reduce(function(c,l){var h=l.split(":"),v=h[0],z=h.slice(1);return v&&0li{position:relative}.fa-li{left:-2em;position:absolute;text-align:center;width:2em;line-height:inherit}.fa-border{border:solid .08em #eee;border-radius:.1em;padding:.2em .25em .15em}.fa-pull-left{float:left}.fa-pull-right{float:right}.fa.fa-pull-left,.fab.fa-pull-left,.fal.fa-pull-left,.far.fa-pull-left,.fas.fa-pull-left{margin-right:.3em}.fa.fa-pull-right,.fab.fa-pull-right,.fal.fa-pull-right,.far.fa-pull-right,.fas.fa-pull-right{margin-left:.3em}.fa-spin{-webkit-animation:fa-spin 2s infinite linear;animation:fa-spin 2s infinite linear}.fa-pulse{-webkit-animation:fa-spin 1s infinite steps(8);animation:fa-spin 1s infinite steps(8)}@-webkit-keyframes fa-spin{0%{-webkit-transform:rotate(0);transform:rotate(0)}100%{-webkit-transform:rotate(360deg);transform:rotate(360deg)}}@keyframes fa-spin{0%{-webkit-transform:rotate(0);transform:rotate(0)}100%{-webkit-transform:rotate(360deg);transform:rotate(360deg)}}.fa-rotate-90{-webkit-transform:rotate(90deg);transform:rotate(90deg)}.fa-rotate-180{-webkit-transform:rotate(180deg);transform:rotate(180deg)}.fa-rotate-270{-webkit-transform:rotate(270deg);transform:rotate(270deg)}.fa-flip-horizontal{-webkit-transform:scale(-1,1);transform:scale(-1,1)}.fa-flip-vertical{-webkit-transform:scale(1,-1);transform:scale(1,-1)}.fa-flip-horizontal.fa-flip-vertical{-webkit-transform:scale(-1,-1);transform:scale(-1,-1)}:root .fa-flip-horizontal,:root .fa-flip-vertical,:root .fa-rotate-180,:root .fa-rotate-270,:root .fa-rotate-90{-webkit-filter:none;filter:none}.fa-stack{display:inline-block;height:2em;position:relative;width:2em}.fa-stack-1x,.fa-stack-2x{bottom:0;left:0;margin:auto;position:absolute;right:0;top:0}.svg-inline--fa.fa-stack-1x{height:1em;width:1em}.svg-inline--fa.fa-stack-2x{height:2em;width:2em}.fa-inverse{color:#fff}.sr-only{border:0;clip:rect(0,0,0,0);height:1px;margin:-1px;overflow:hidden;padding:0;position:absolute;width:1px}.sr-only-focusable:active,.sr-only-focusable:focus{clip:auto;height:auto;margin:0;overflow:visible;position:static;width:auto}";if("fa"!==l||h!==c){var z=new RegExp("\\.fa\\-","g"),e=new RegExp("\\."+c,"g");v=v.replace(z,"."+l+"-").replace(e,"."+h)}return v};var Qc=function(){function c(){w(this,c),this.definitions={}}return y(c,[{key:"add",value:function(){for(var l=this,c=arguments.length,h=Array(c),v=0;v { + render(): JSX.Element { + return ( +
+

{this.props.title}

+
+ +
+ {this.props.children} +
+ ); + } +} diff --git a/client/src/components/CarContainer.tsx b/client/src/components/CarContainer.tsx new file mode 100644 index 0000000..40725b6 --- /dev/null +++ b/client/src/components/CarContainer.tsx @@ -0,0 +1,128 @@ +import * as React from "react"; +import { connect } from "react-redux"; +import * as Redux from "redux"; +import { addCar, deleteCar, updateCar } from "../data/actions"; +import { ApplicationState, Car } from "../data/models"; +import CarDetailsDialog from "./CarDetailsDialog"; +import CarEntryDialog from "./CarEntryDialog"; +import CarListItem from "./CarListItem"; +import CarRemoveDialog from "./CarRemoveDialog"; + +// Properties to get from Redux Store +interface StoreProps { + cars: Car[]; +} + +// Properties to get for Redux Dispatch +interface DispatchProps { + insertCar: (car: Car) => void; + updateCar: (car: Car) => void; + deleteCar: (car: Car) => void; +} + +// Properties external to Redux Store +export interface OwnProps { + // No properties +} + +// State inside (if stateful) Component +interface OwnState { + selectedCar?: Car; +} + +type CombinedProps = StoreProps & DispatchProps & OwnProps; + +class CarContainer extends React.Component { + constructor(props: CombinedProps) { + super(props); + this.state = { selectedCar: null }; + } + + handleCarSelected = (selectedCar: Car) => { + this.setState({ selectedCar }); + }; + + render(): JSX.Element { + return ( +
+ {/* Table showing the contents */} + + + + + + + + + + + + + + {this.props.cars.map(car => ( + + ))} + {this.props.children} + +
ManufacturerMakeModelYearEditDelete
+ + + + +
+ ); + } +} + +function mapStateToProps( + state: ApplicationState, + ownProps: OwnProps +): StoreProps { + return { + cars: state.cars + }; +} + +function mapDispatchToProps( + dispatch: Redux.Dispatch, + ownProps: OwnProps +): DispatchProps { + return { + insertCar: car => dispatch(addCar(car)), + updateCar: car => dispatch(updateCar(car)), + deleteCar: car => dispatch(deleteCar(car)) + }; +} + +const wrapper = connect( + mapStateToProps, + mapDispatchToProps +); + +export default wrapper(CarContainer); diff --git a/client/src/components/CarDetailsDialog.tsx b/client/src/components/CarDetailsDialog.tsx new file mode 100644 index 0000000..758f6b0 --- /dev/null +++ b/client/src/components/CarDetailsDialog.tsx @@ -0,0 +1,163 @@ +import * as React from "react"; +import { connect } from "react-redux"; +import * as Redux from "redux"; +import { ApplicationState, Car } from "../data/models"; + +// Properties to get from Redux Store +interface StoreProps { + // No properties +} + +// Properties to get for Redux Dispatch +interface DispatchProps { + // No properties +} + +// Properties external to Redux Store +export interface OwnProps { + id: string; + car?: Car; +} + +// State inside (if stateful) component +interface OwnState { + // No properties +} + +type CombinedProps = StoreProps & DispatchProps & OwnProps; + +class CarEntryDialog extends React.Component { + constructor(props: CombinedProps) { + super(props); + } + + getTitle = () => "Car Details"; + getSubtitle = () => "The car details are given below"; + + render(): JSX.Element { + return ( + + ); + } +} + +function mapStateToProps( + state: ApplicationState, + ownProps: OwnProps +): StoreProps { + return { + // No properties + }; +} + +function mapDispatchToProps( + dispatch: Redux.Dispatch, + ownProps: OwnProps +): DispatchProps { + return { + // No properties + }; +} + +const wrapper = connect( + mapStateToProps, + mapDispatchToProps +); + +export default wrapper(CarEntryDialog); diff --git a/client/src/components/CarEntryDialog.tsx b/client/src/components/CarEntryDialog.tsx new file mode 100644 index 0000000..9ffb535 --- /dev/null +++ b/client/src/components/CarEntryDialog.tsx @@ -0,0 +1,236 @@ +import * as React from "react"; +import { connect } from "react-redux"; +import * as Redux from "redux"; +import { ApplicationState, Car } from "../data/models"; + +// Properties to get from Redux Store +interface StoreProps { + // No properties +} + +// Properties to get for Redux Dispatch +interface DispatchProps { + // No properties +} + +// Properties external to Redux Store +export interface OwnProps { + id: string; + mode: "insert" | "update"; + car?: Car; + onSave: (car: Car) => void; +} + +// State inside (if stateful) component +interface OwnState { + newCar: Car; +} + +type CombinedProps = StoreProps & DispatchProps & OwnProps; + +class CarEntryDialog extends React.Component { + constructor(props: CombinedProps) { + super(props); + // Initially set all variables to empty + this.state = { + newCar: this.props.car ? { ...this.props.car } : this.newCarState() + }; + } + + componentWillUpdate(nextProps, nextState) { + // Either set the modifying car's props or blank props as state, when component is updated + if (this.props != nextProps) { + if (nextProps.mode === "update" && nextProps.car) { + nextState.newCar = { ...nextProps.car }; + } else if (nextProps.mode === "insert") { + nextState.newCar = this.newCarState(); + } + } + } + + newCarState = (): Car => { + return { id: 0, manufacturer: "", make: "", model: "", year: 2018 }; + }; + + initState = () => { + this.setState({newCar : this.newCarState()}); + } + + handleEntry = event => { + let id = this.state.newCar.id; + let manufacturer = this.refs.manufacturer["value"]; + let make = this.refs.make["value"]; + let model = this.refs.model["value"]; + let year = this.refs.year["value"]; + let newCar = { id, manufacturer, make, model, year }; + this.props.onSave({ + ...this.state.newCar, + manufacturer, + make, + model, + year + }); + this.initState(); + }; + + getTitle = () => + this.props.mode == "insert" ? "Add New Item" : "Update Item"; + getSubtitle = () => + this.props.mode == "insert" + ? "Enter item details and click on Save to Add." + : "Please update the details and click on Save."; + + updateState = (event): void => { + let key: String = event.currentTarget.id; + let value: String = event.currentTarget.value; + switch (key) { + case "manufacturer": { + this.setState({ + newCar: { ...this.state.newCar, manufacturer: value } + }); + break; + } + case "make": { + this.setState({ newCar: { ...this.state.newCar, make: value } }); + break; + } + case "model": { + this.setState({ newCar: { ...this.state.newCar, model: value } }); + break; + } + case "year": { + this.setState({ + newCar: { ...this.state.newCar, year: Number(value) } + }); + break; + } + } + }; + + render(): JSX.Element { + return ( + + ); + } +} + +function mapStateToProps( + state: ApplicationState, + ownProps: OwnProps +): StoreProps { + return { + // No properties + }; +} + +function mapDispatchToProps( + dispatch: Redux.Dispatch, + ownProps: OwnProps +): DispatchProps { + return { + // No properties + }; +} + +const wrapper = connect( + mapStateToProps, + mapDispatchToProps +); + +export default wrapper(CarEntryDialog); diff --git a/client/src/components/CarListItem.tsx b/client/src/components/CarListItem.tsx new file mode 100644 index 0000000..d87f6b5 --- /dev/null +++ b/client/src/components/CarListItem.tsx @@ -0,0 +1,117 @@ +import * as React from "react"; +import { connect } from "react-redux"; +import * as Redux from "redux"; +import { deleteCar, updateCar } from "../data/actions"; +import { ApplicationState, Car } from "../data/models"; + +// Properties to get from Redux Store +interface StoreProps { + // No properties +} + +// Properties to get for Redux Dispatch +interface DispatchProps { + updateCar: (car: Car) => void; + deleteCar: (car: Car) => void; +} + +// Properties external to Redux Store +export interface OwnProps { + car: Car; + editTarget: String; + deleteTarget: String; + viewTarget: String; + notifySelected: (car: Car) => void; +} + +// State inside (if stateful) component +interface OwnState { + // No properties +} + +type CombinedProps = StoreProps & DispatchProps & OwnProps; + +class CarListItem extends React.Component { + constructor(props: CombinedProps) { + super(props); + } + + notifySelect = event => { + // Notify that this car was selected + this.props.notifySelected(this.props.car); + }; + + render(): JSX.Element { + const { manufacturer, make, model, year } = this.props.car; + return ( + + + + + {manufacturer} + + + + + {make} + + + {model} + + + {year} + + + + + + + + + + + + + ); + } +} + +function mapStateToProps( + state: ApplicationState, + ownProps: OwnProps +): StoreProps { + return { + // Nothing to map + }; +} + +function mapDispatchToProps( + dispatch: Redux.Dispatch, + ownProps: OwnProps +): DispatchProps { + return { + updateCar: car => dispatch(updateCar(car)), + deleteCar: car => dispatch(deleteCar(car)) + }; +} + +const wrapper = connect( + mapStateToProps, + mapDispatchToProps +); + +export default wrapper(CarListItem); diff --git a/client/src/components/CarRemoveDialog.tsx b/client/src/components/CarRemoveDialog.tsx new file mode 100644 index 0000000..442fbc4 --- /dev/null +++ b/client/src/components/CarRemoveDialog.tsx @@ -0,0 +1,113 @@ +import * as React from "react"; +import { connect } from "react-redux"; +import * as Redux from "redux"; +import { ApplicationState, Car } from "../data/models"; + +// Properties to get from Redux Store +interface StoreProps { + // No properties +} + +// Properties to get for Redux Dispatch +interface DispatchProps { + // No properties +} + +// Properties external to Redux Store +export interface OwnProps { + id: string; + car: Car; + onConfirm: (car: Car) => void; +} + +// State inside (if stateful) component +interface OwnState { + // No properties +} + +type CombinedProps = StoreProps & DispatchProps & OwnProps; + +class CarRemoveDialog extends React.Component { + constructor(props: CombinedProps) { + super(props); + } + + handleConfirm = event => { + this.props.onConfirm(this.props.car); + }; + + title = "Remove Item"; + subtitle = "Are you sure you want to remove this item?"; + + render(): JSX.Element { + return ( + + ); + } +} + +function mapStateToProps( + state: ApplicationState, + ownProps: OwnProps +): StoreProps { + return { + cars: state.cars + }; +} + +function mapDispatchToProps( + dispatch: Redux.Dispatch, + ownProps: OwnProps +): DispatchProps { + return { + // No properties + }; +} + +const wrapper = connect( + mapStateToProps, + mapDispatchToProps +); + +export default wrapper(CarRemoveDialog); diff --git a/client/src/data/actions.tsx b/client/src/data/actions.tsx new file mode 100644 index 0000000..72d7553 --- /dev/null +++ b/client/src/data/actions.tsx @@ -0,0 +1,34 @@ +import { ADD_CAR, DELETE_CAR, LOAD_INVENTORY, UPDATE_CAR } from "./constants"; +import { Car, StoreAction } from "./models"; + +// Template Add New Car to Redux Store +export const addCar = function(car: Car): StoreAction { + return { + type: ADD_CAR, + payload: car + }; +}; + +// Template Update Existing Car in Redux Store +export const updateCar = function(car: Car): StoreAction { + return { + type: UPDATE_CAR, + payload: car + }; +}; + +// Template Delete Existing Car in Redux Store +export const deleteCar = function(car: Car): StoreAction { + return { + type: DELETE_CAR, + payload: car + }; +}; + +// Template that gets remote inventory and updates current +export const loadInventory = function(cars: Car[]): StoreAction { + return { + type: LOAD_INVENTORY, + payload: cars + }; +}; diff --git a/client/src/data/constants.tsx b/client/src/data/constants.tsx new file mode 100644 index 0000000..9bda57d --- /dev/null +++ b/client/src/data/constants.tsx @@ -0,0 +1,8 @@ +// Updating local inventory +export const ADD_CAR = "ADD_CAR"; +export const UPDATE_CAR = "UPDATE_CAR"; +export const DELETE_CAR = "DELETE_CAR"; +// Getting remote inventory +export const LOAD_INVENTORY = "GET_INVENTORY"; +// URLS +export const SERVER_URL = "http://localhost:5000"; diff --git a/client/src/data/models.tsx b/client/src/data/models.tsx new file mode 100644 index 0000000..5435f2d --- /dev/null +++ b/client/src/data/models.tsx @@ -0,0 +1,20 @@ +import { Action } from "redux"; + +// Structure of Car +export interface Car { + id: Number; + manufacturer: String; + make: String; + model: String; + year: Number; +} + +// Structure of Application State +export interface ApplicationState { + cars: Car[]; +} + +// Structure of Store Action +export interface StoreAction extends Action { + payload?: V; +} diff --git a/client/src/data/req.tsx b/client/src/data/req.tsx new file mode 100644 index 0000000..dc990d8 --- /dev/null +++ b/client/src/data/req.tsx @@ -0,0 +1,36 @@ +import * as Request from "request"; +import { SERVER_URL } from "./constants"; +import { Car } from "./models"; +import { init } from "./store"; + +const options = (url: string, json) => { + return { + url: `${SERVER_URL}${url}`, + json + }; +}; + +export const getInventory = (callback: (cars: Car[]) => void): void => { + Request.get(options("/api/car", true), (err, res, body) => { + if (err) { + callback([]); + } else { + callback(body); + } + }); +}; + +export const addItem = (car: Car): void => { + console.log(car); + Request.post(options("/api/car", car), (err, res, body) => { + init(); + }); +}; + +export const updateItem = (car: Car): void => { + Request.put(options(`/api/car/${car.id}`, car)); +}; + +export const removeItem = (car: Car): void => { + Request.delete(options(`/api/car/${car.id}`, true)); +}; diff --git a/client/src/data/store.tsx b/client/src/data/store.tsx new file mode 100644 index 0000000..e397b8d --- /dev/null +++ b/client/src/data/store.tsx @@ -0,0 +1,80 @@ +import { createStore, Store } from "redux"; +import { loadInventory } from "./actions"; +import { ADD_CAR, DELETE_CAR, LOAD_INVENTORY, UPDATE_CAR } from "./constants"; +import { ApplicationState, StoreAction } from "./models"; +import * as req from "./req"; + +const initialState: ApplicationState = { + cars: [] +}; + +// Root Reducer for all actions in the application +function reduce( + state: ApplicationState = initialState, + action: StoreAction +): ApplicationState { + let nextState = state; + switch (action.type) { + case ADD_CAR: { + // Return all cars plus the new one + nextState = { + ...state, + cars: [...state.cars, action.payload] + }; + req.addItem(action.payload); + break; + } + case UPDATE_CAR: { + // Return all cars along with the updated (merged) car + let current = state.cars.filter(c => c.id === action.payload.id)[0]; + if (current) { + let index = state.cars.indexOf(current); + let newCars = [...state.cars]; + newCars[index] = action.payload; + nextState = { + ...state, + cars: newCars + }; + req.updateItem(action.payload); + } + break; + } + case DELETE_CAR: { + // Return all cars except the deleted one + let current = state.cars.filter(c => c.id === action.payload.id)[0]; + if (current) { + let index = state.cars.indexOf(current); + let newCars = [...state.cars]; + newCars.splice(index, 1); + nextState = { + ...state, + cars: newCars + }; + req.removeItem(action.payload); + } + break; + } + case LOAD_INVENTORY: { + // Reload inventory with new data + nextState = { + ...state, + cars: action.payload + }; + break; + } + } + return nextState; +} + +const store: Store> = createStore( + reduce +); + +export const init = () => { + req.getInventory(cars => { + store.dispatch(loadInventory(cars)); + }); +}; + +init(); +export default store; diff --git a/client/src/index.tsx b/client/src/index.tsx new file mode 100644 index 0000000..35ba276 --- /dev/null +++ b/client/src/index.tsx @@ -0,0 +1,12 @@ +import * as React from "react"; +import * as ReactDOM from "react-dom"; +import { Provider } from "react-redux"; +import { App } from "./components/App"; +import Store from "./data/store"; + +ReactDOM.render( + + + , + document.getElementById("example") +); diff --git a/client/tsconfig.json b/client/tsconfig.json new file mode 100644 index 0000000..737a003 --- /dev/null +++ b/client/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "outDir": "./dist/", + "sourceMap": true, + "noImplicitAny": false, + "module": "commonjs", + "target": "es6", + "jsx": "react" + }, + "include": [ + "./src/**/*" + ], +} \ No newline at end of file diff --git a/client/webpack.config.js b/client/webpack.config.js new file mode 100644 index 0000000..590a774 --- /dev/null +++ b/client/webpack.config.js @@ -0,0 +1,55 @@ +module.exports = { + + node: { + console: false, + global: true, + process: true, + __filename: 'mock', + __dirname: 'mock', + Buffer: true, + setImmediate: true, + fs : 'empty', + net : 'empty', + tls : 'empty' + }, + + entry: "./src/index.tsx", + output: { + filename: "bundle.js", + path: __dirname + "/dist" + }, + + // Enable sourcemaps for debugging webpack's output. + devtool: "source-map", + + resolve: { + // Add '.ts' and '.tsx' as resolvable extensions. + extensions: [".ts", ".tsx", ".js", ".json"] + }, + + module: { + rules: [ + // All files with a '.ts' or '.tsx' extension will be handled by 'awesome-typescript-loader'. + { + test: /\.tsx?$/, + loader: "awesome-typescript-loader" + }, + + // All output '.js' files will have any sourcemaps re-processed by 'source-map-loader'. + { + enforce: "pre", + test: /\.js$/, + loader: "source-map-loader" + } + ] + }, + + // When importing a module whose path matches one of the following, just + // assume a corresponding global variable exists and use that instead. + // This is important because it allows us to avoid bundling all of our + // dependencies, which allows browsers to cache those libraries between builds. + externals: { + "react": "React", + "react-dom": "ReactDOM" + }, +}; \ No newline at end of file