diff --git a/.babelrc b/.babelrc
index da1b885..640da2b 100644
--- a/.babelrc
+++ b/.babelrc
@@ -10,5 +10,6 @@
"react-hmre"
]
}
- }
+ },
+ "plugins": "syntax-decorators"
}
diff --git a/.gitignore b/.gitignore
index aa1f911..87874a5 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,2 +1,3 @@
bundle.js
node_modules
+.DS_Store
diff --git a/app/actions/items.js b/app/actions/items.js
new file mode 100644
index 0000000..9dbebef
--- /dev/null
+++ b/app/actions/items.js
@@ -0,0 +1,33 @@
+export const CREATE_ITEM = 'CREATE_ITEM'
+export function createItem (item) {
+ return {
+ type: CREATE_ITEM,
+ item: {
+ ...item
+ }
+ }
+}
+
+export const UPDATE_ITEM = 'UPDATE_ITEM'
+export function updateItem (updatedItem) {
+ return {
+ type: UPDATE_ITEM,
+ updatedItem
+ }
+}
+
+export const DELETE_ITEM = 'DELETE_ITEM'
+export function deleteItem (id) {
+ return {
+ type: DELETE_ITEM,
+ id
+ }
+}
+
+export const CHECK_ITEM = 'CHECK_ITEM'
+export function checkItem (id) {
+ return {
+ type: CHECK_ITEM,
+ id
+ }
+}
diff --git a/app/actions/lists.js b/app/actions/lists.js
new file mode 100644
index 0000000..8be7229
--- /dev/null
+++ b/app/actions/lists.js
@@ -0,0 +1,47 @@
+import uuid from 'node-uuid'
+
+export const CREATE_LIST = 'CREATE_LIST'
+export function createList (list) {
+ return {
+ type: CREATE_LIST,
+ list: {
+ id: uuid.v4(),
+ items: list.items || [],
+ ...list
+ }
+ }
+}
+
+export const UPDATE_LIST = 'UPDATE_LIST'
+export function updateList (updatedList) {
+ return {
+ type: UPDATE_LIST,
+ ...updatedList
+ }
+}
+
+export const DELETE_LIST = 'DELETE_LIST'
+export function deleteList (id) {
+ return {
+ type: DELETE_LIST,
+ id
+ }
+}
+
+export const CONNECT_TO_LIST = 'CONNECT_TO_LIST'
+export function connectToList (listId, itemId) {
+ return {
+ type: CONNECT_TO_LIST,
+ listId,
+ itemId
+ }
+}
+
+export const DISCONNECT_FROM_LIST = 'DISCONNECT_FROM_LIST'
+export function disconnectFromList (listId, itemId) {
+ return {
+ type: DISCONNECT_FROM_LIST,
+ listId,
+ itemId
+ }
+}
diff --git a/app/actions/modal.js b/app/actions/modal.js
new file mode 100644
index 0000000..ba1107e
--- /dev/null
+++ b/app/actions/modal.js
@@ -0,0 +1,14 @@
+export const OPEN_MODAL = 'OPEN_MODAL'
+export function openModal (listId) {
+ return {
+ type: OPEN_MODAL,
+ listId: listId
+ }
+}
+
+export const CLOSE_MODAL = 'CLOSE_MODAL'
+export function closeModal () {
+ return {
+ type: CLOSE_MODAL
+ }
+}
diff --git a/app/components/App.jsx b/app/components/App.jsx
index 56912f8..2c41e5a 100644
--- a/app/components/App.jsx
+++ b/app/components/App.jsx
@@ -1,8 +1,31 @@
-import React from 'react';
-import Note from './Note.jsx';
+import React from 'react'
+import Lists from './Lists'
+import { connect } from 'react-redux'
+import { createList } from '../actions/lists'
+import Nav from './Nav'
+
+export class App extends React.Component {
+
+ handleClick = () => {
+ this.props.dispatch(createList({title: 'New Shopping List'}))
+ }
-export default class App extends React.Component {
render() {
- return ;
+ const lists = this.props.lists
+
+ return (
+
+
+
+
+ )
}
}
+
+export default connect(state => ({ lists: state.lists }))(App)
diff --git a/app/components/Editor.jsx b/app/components/Editor.jsx
new file mode 100644
index 0000000..1880c6f
--- /dev/null
+++ b/app/components/Editor.jsx
@@ -0,0 +1,65 @@
+import React from 'react'
+
+export default class Editor extends React.Component {
+ render() {
+ const { value, onEdit, onValueClick, isEditing, ...props} = this.props
+
+ return (
+
+ {isEditing ? this.renderEdit() : this.renderValue()}
+
+ )
+ }
+
+ renderEdit = () => {
+ return (
+ event ? event.selectionStart = this.props.value.length : null}
+ autoFocus={true}
+ defaultValue={this.props.value}
+ onBlur={this.finishEdit}
+ onKeyPress={this.checkEnter}
+ />
+ )
+ }
+
+ renderValue = () => {
+ const onDelete = this.props.onDelete
+
+ return (
+
+
+ {this.props.value}
+ {onDelete && this.renderDelete()}
+
+
+ )
+ }
+
+ renderDelete = () => {
+ return
+ }
+
+ checkEnter = (event) => {
+ if(event.key === 'Enter') {
+ this.finishEdit(event)
+ }
+ }
+
+ finishEdit = (event) => {
+ const value = event.target.value
+
+ if(this.props.onEdit && value.trim()) {
+ this.props.onEdit(value)
+ }
+ }
+}
+
+const { func, string, bool } = React.PropTypes
+
+Editor.propTypes = {
+ value: string.isRequired,
+ onEdit: func.isRequired,
+ isEditing: bool
+}
diff --git a/app/components/Item.jsx b/app/components/Item.jsx
new file mode 100644
index 0000000..3b62d27
--- /dev/null
+++ b/app/components/Item.jsx
@@ -0,0 +1,21 @@
+import React from 'react'
+
+export default class Item extends React.Component {
+ render () {
+ const { populateForm, onDelete, onCheck, ...props } = this.props
+ const checkItem = this.props.checked ? 'item checked' : 'item'
+
+ return (
+
+
+
SKU: {this.props.sku}
+ ITEM: {this.props.text}
+ PRICE: ${this.props.price}
+
+
+
+
+
+ )
+ }
+}
diff --git a/app/components/ItemForm.jsx b/app/components/ItemForm.jsx
new file mode 100644
index 0000000..59986ca
--- /dev/null
+++ b/app/components/ItemForm.jsx
@@ -0,0 +1,69 @@
+import React from 'react'
+import { reduxForm } from 'redux-form'
+import validate from '../validators/validate'
+
+class ItemForm extends React.Component {
+ render() {
+ const {
+ fields: {id, sku, text, price},
+ handleSubmit,
+ onEdit,
+ destroyForm,
+ closeModal
+ } = this.props
+
+ return (
+
+ )
+ }
+}
+
+export default ItemForm = reduxForm({
+ form: 'items',
+ fields: ['id', 'sku', 'text', 'price', 'checked'],
+ validate
+})(ItemForm);
diff --git a/app/components/Items.jsx b/app/components/Items.jsx
new file mode 100644
index 0000000..69f19e0
--- /dev/null
+++ b/app/components/Items.jsx
@@ -0,0 +1,37 @@
+import React from 'react'
+import Item from './Item'
+
+export default class Items extends React.Component {
+ render () {
+ const {items, populateForm, onDelete, onCheck, ...props} = this.props
+
+ return (
+ {items.map((item) =>
+ -
+
+ )}
+ )
+ }
+}
+
+const { string, bool, func, arrayOf, shape} = React.PropTypes
+
+Items.propTypes = {
+ items: arrayOf(shape({
+ id: string.isRequired,
+ sku: string.isRequired,
+ text: string.isRequired,
+ price: string.isRequired,
+ }).isRequired).isRequired,
+ populateForm: func.isRequired
+}
diff --git a/app/components/List.jsx b/app/components/List.jsx
new file mode 100644
index 0000000..648182f
--- /dev/null
+++ b/app/components/List.jsx
@@ -0,0 +1,153 @@
+import React from 'react'
+import { connect } from 'react-redux'
+import { bindActionCreators } from 'redux'
+import * as listActionCreators from '../actions/lists'
+import * as itemActionCreators from '../actions/items'
+import * as modalActionCreators from '../actions/modal'
+import { reset, initialize } from 'redux-form'
+import Items from './Items'
+import Editor from './Editor'
+import Modal from './Modal'
+import ItemForm from './ItemForm'
+import uuid from 'node-uuid'
+
+export class List extends React.Component {
+ constructor(props) {
+ super(props)
+
+ this.resetFrom = this.resetForm.bind(this)
+ this.createItem = this.createItem.bind(this)
+ }
+
+ deleteList (listId, e) {
+ e.stopPropagation()
+
+ this.props.listActions.deleteList(listId)
+ this.props.modalActions.closeModal()
+ }
+
+ resetForm () {
+ this.props.reset('items')
+ }
+
+ createItem (item) {
+ let items = {
+ id: uuid.v4(),
+ sku: item.sku,
+ text: item.text,
+ price: item.price,
+ checked: item.checked
+ }
+
+ this.props.itemActions.createItem(items)
+ this.props.listActions.connectToList(this.props.list.id, items.id)
+ this.resetForm()
+ this.props.modalActions.closeModal()
+ }
+
+ populateForm (item) {
+ const { id, sku, text, price } = item
+ this.props.dispatch(initialize('items', {
+ id, sku, text, price
+ }, ['id', 'sku', 'text', 'price']))
+ this.props.modalActions.openModal()
+ }
+
+ deleteItem(listId, itemId) {
+ this.props.listActions.disconnectFromList(listId, itemId)
+ this.props.itemActions.deleteItem(itemId)
+ }
+
+ checkItem(id) {
+ this.props.itemActions.checkItem(id)
+ }
+
+ total() {
+ return this.props.listItems.reduce((prev, item) => {
+ return prev + parseFloat(item.price)
+ }, 0)
+ }
+
+ render () {
+ const { list, listItems, isModalOpen, ...props } = this.props
+ const { updateList, deleteList } = this.props.listActions
+ const { updateItem } = this.props.itemActions
+ const { openModal, closeModal } = this.props.modalActions
+ const listId = list.id
+
+ return (
+
+
+
+
+
+
+
updateList({id: listId, isEditing: true})} >
+
+
updateList({id: listId, title, isEditing: false})}>
+
+
+
+
+
+
+
+
+
{this.total()}
+
+
this.populateForm(item)}
+ onDelete={(itemId) => this.deleteItem(listId, itemId)}
+ onCheck={(id) => this.checkItem(id)}>
+
+
+
+
+
+
+
+ )
+ }
+}
+
+function mapStateToProps (state, props) {
+ return {
+ lists: state.lists,
+ listItems: props.list.items.map((id) => state.items[
+ state.items.findIndex((item) => item.id === id)
+ ]).filter((item) => item),
+ isModalOpen: state.modal.isOpen
+ }
+}
+
+function mapDispatchToProps (dispatch) {
+ return {
+ listActions: bindActionCreators(listActionCreators, dispatch),
+ itemActions: bindActionCreators(itemActionCreators, dispatch),
+ modalActions: bindActionCreators(modalActionCreators, dispatch),
+ reset: bindActionCreators(reset, dispatch),
+ dispatch: bindActionCreators(dispatch, dispatch)
+ }
+}
+
+const { string, arrayOf, shape } = React.PropTypes
+
+List.propTypes = {
+ lists: arrayOf(shape({
+ id: string.isRequired,
+ title: string.isRequired
+ }).isRequired)
+}
+
+export default connect(mapStateToProps, mapDispatchToProps)(List)
diff --git a/app/components/Lists.jsx b/app/components/Lists.jsx
new file mode 100644
index 0000000..63d56d6
--- /dev/null
+++ b/app/components/Lists.jsx
@@ -0,0 +1,10 @@
+import React from 'react'
+import List from './List.jsx'
+
+export default ({lists}) => {
+ return (
+ {lists.map((list) =>
+
+ )}
+ )
+}
diff --git a/app/components/Modal.jsx b/app/components/Modal.jsx
new file mode 100644
index 0000000..23a83c0
--- /dev/null
+++ b/app/components/Modal.jsx
@@ -0,0 +1,9 @@
+import React from 'react'
+
+export default class Modal extends React.Component {
+ render() {
+ let modalClass = this.props.openModal ? 'modalIsOpen' : 'modalIsClosed'
+
+ return {this.props.children}
+ }
+}
diff --git a/app/components/Nav.jsx b/app/components/Nav.jsx
new file mode 100644
index 0000000..f1e721f
--- /dev/null
+++ b/app/components/Nav.jsx
@@ -0,0 +1,5 @@
+import React from 'react'
+
+const Nav = (props) => {props.children}
+
+export default Nav
diff --git a/app/components/Note.jsx b/app/components/Note.jsx
deleted file mode 100644
index 5d49b21..0000000
--- a/app/components/Note.jsx
+++ /dev/null
@@ -1,3 +0,0 @@
-import React from 'react';
-
-export default () => Learn React and Webpack!
;
diff --git a/app/index.jsx b/app/index.jsx
index 5d1364e..5d1043f 100644
--- a/app/index.jsx
+++ b/app/index.jsx
@@ -1,7 +1,13 @@
-import './main.css';
+import './stylesheets/main.sass'
+import React from 'react'
+import ReactDOM from 'react-dom'
+import App from './components/App.jsx'
+import { Provider } from 'react-redux'
+import store from './stores/store'
-import React from 'react';
-import ReactDOM from 'react-dom';
-import App from './components/App.jsx';
-
-ReactDOM.render(, document.getElementById('app'));
+ReactDOM.render(
+
+
+ ,
+ document.getElementById('app')
+)
diff --git a/app/main.css b/app/main.css
deleted file mode 100644
index 62a79b7..0000000
--- a/app/main.css
+++ /dev/null
@@ -1,3 +0,0 @@
-body {
- background: cornsilk;
-}
\ No newline at end of file
diff --git a/app/reducers/index.js b/app/reducers/index.js
new file mode 100644
index 0000000..0308a1c
--- /dev/null
+++ b/app/reducers/index.js
@@ -0,0 +1,14 @@
+import { combineReducers } from 'redux'
+import items from './items'
+import lists from './lists'
+import modal from './modal'
+import {reducer as formReducer} from 'redux-form'
+
+const ShoppingListReducers = combineReducers({
+ form: formReducer,
+ items,
+ lists,
+ modal
+})
+
+export default ShoppingListReducers
diff --git a/app/reducers/items.js b/app/reducers/items.js
new file mode 100644
index 0000000..494700c
--- /dev/null
+++ b/app/reducers/items.js
@@ -0,0 +1,41 @@
+import * as types from '../actions/items'
+
+const initialState = []
+
+export default function items(state = initialState, action) {
+ switch (action.type) {
+ case types.CREATE_ITEM:
+ return [ ...state, action.item ]
+
+ case types.UPDATE_ITEM:
+ return state.map((item) => {
+ if (item.id === action.updatedItem.id) {
+ return Object.assign({}, action.updatedItem)
+ }
+
+ return item
+ })
+
+ case types.DELETE_ITEM:
+ return state.filter((item) => item.id !== action.id)
+
+ case types.RESET_FORM:
+ return {}
+
+ case types.CHECK_ITEM:
+ return state.map((item) => {
+ if (item.id === action.id) {
+ return Object.assign({}, item,
+ {
+ checked: !item.checked
+ }
+ )
+ }
+
+ return item
+ })
+
+ default:
+ return state
+ }
+}
diff --git a/app/reducers/lists.js b/app/reducers/lists.js
new file mode 100644
index 0000000..8e1f6cb
--- /dev/null
+++ b/app/reducers/lists.js
@@ -0,0 +1,59 @@
+import * as types from '../actions/lists'
+
+const initialState = []
+
+export default function lists (state = initialState, action) {
+ switch (action.type) {
+ case types.CREATE_LIST:
+ return [ ...state, action.list ]
+
+ case types.UPDATE_LIST:
+ return state.map((list) => {
+ if (list.id === action.id) {
+ return Object.assign({}, list, action)
+ }
+
+ return list
+ })
+
+ case types.DELETE_LIST:
+ return state.filter((list) => list.id !== action.id)
+
+ case types.CONNECT_TO_LIST:
+ const listId = action.listId
+ const itemId = action.itemId
+
+ return state.map((list) => {
+ const index = list.items.indexOf(itemId)
+
+ if (index >= 0) {
+ return Object.assign({}, list, {
+ items: list.items.length > 1 ? list.items.slice(0, index).concat(
+ list.items.slice(index + 1)) : []
+ })
+ }
+
+ if (list.id === listId) {
+ return Object.assign({}, list, {
+ items: [...list.items, itemId]
+ })
+ }
+
+ return list
+ })
+
+ case types.DISCONNECT_FROM_LIST:
+ return state.map((list) => {
+ if (list.id === action.listId) {
+ return Object.assign({}, list, {
+ items: list.items.filter((id) => id !== action.itemId)
+ })
+ }
+
+ return list
+ })
+
+ default:
+ return state
+ }
+}
diff --git a/app/reducers/modal.js b/app/reducers/modal.js
new file mode 100644
index 0000000..5d3b1d7
--- /dev/null
+++ b/app/reducers/modal.js
@@ -0,0 +1,23 @@
+import * as types from '../actions/modal'
+
+const initialState = {
+ isOpen: false
+}
+
+export default function modal (state = initialState, action) {
+ switch (action.type) {
+ case types.OPEN_MODAL:
+ return {
+ isOpen: true,
+ listId: action.listId
+ }
+
+ case types.CLOSE_MODAL:
+ return {
+ isOpen: false
+ }
+
+ default:
+ return state
+ }
+}
diff --git a/app/stores/store.js b/app/stores/store.js
new file mode 100644
index 0000000..e13b277
--- /dev/null
+++ b/app/stores/store.js
@@ -0,0 +1,16 @@
+import { createStore, applyMiddleware } from 'redux'
+import thunkMiddleware from 'redux-thunk'
+import createLogger from 'redux-logger'
+import ShoppingListReducers from '../reducers'
+
+const loggerMiddleware = createLogger()
+
+const store = createStore(
+ ShoppingListReducers,
+ applyMiddleware(
+ thunkMiddleware,
+ loggerMiddleware
+ )
+)
+
+export default store
diff --git a/app/stylesheets/base/layout.sass b/app/stylesheets/base/layout.sass
new file mode 100644
index 0000000..1d82996
--- /dev/null
+++ b/app/stylesheets/base/layout.sass
@@ -0,0 +1,7 @@
+////////////////////
+// Layout Structure
+////////////////////
+
+body
+ // background-color: $background-primary
+ background-color: $cornsilk
diff --git a/app/stylesheets/base/scale.sass b/app/stylesheets/base/scale.sass
new file mode 100644
index 0000000..6602c38
--- /dev/null
+++ b/app/stylesheets/base/scale.sass
@@ -0,0 +1,8 @@
+////////////////////
+// Scaling REM
+////////////////////
+
+html
+ font-size: $small-rem
+ @media #{$large-up}
+ font-size: $base-rem
diff --git a/app/stylesheets/base/type.sass b/app/stylesheets/base/type.sass
new file mode 100644
index 0000000..e9a20e2
--- /dev/null
+++ b/app/stylesheets/base/type.sass
@@ -0,0 +1,28 @@
+///////////////////////////
+// Font Defaults
+///////////////////////////
+
+body
+ color: $font-color
+ font:
+ family: $font-family
+ size: $font-size
+ weight: $font-weight
+ line-height: $line-height
+
+///////////////////////////
+// Font Primary Elements
+///////////////////////////
+
+h1, .h1
+ font-size: $h1-size
+a
+ color: inherit
+ cursor: pointer
+
+///////////////////////////
+// Generic Text Selectors
+///////////////////////////
+
+u
+ text-decoration: underline
diff --git a/app/stylesheets/components/form.sass b/app/stylesheets/components/form.sass
new file mode 100644
index 0000000..ed3d903
--- /dev/null
+++ b/app/stylesheets/components/form.sass
@@ -0,0 +1,34 @@
+//////////////////////////
+// Form
+//////////////////////////
+
+.label
+ float: left
+
+.error
+ margin: 0.2em 0em 0.2em 0em
+ padding: 0.3em
+ color: $color-alert
+
+.input
+ border: none
+ border-bottom: 1px solid $color-secondary
+ margin-left: 1em
+ min-width: 75%
+
+//////////////////////////
+// Button
+/////////////////////////
+
+.btn
+ border-radius: $border-radius-round
+ border-style: none
+ background-color: $deep-salmon
+
+.button
+ margin: 10% 0% 0% 12%
+ &__form
+ background-color: $burlywood
+ @media #{$medium-up}
+ margin: 10% 0% 0% 15%
+
diff --git a/app/stylesheets/components/item.sass b/app/stylesheets/components/item.sass
new file mode 100644
index 0000000..6eba1b3
--- /dev/null
+++ b/app/stylesheets/components/item.sass
@@ -0,0 +1,41 @@
+///////////////////////////////////////////
+// Item
+///////////////////////////////////////////
+
+.items
+ margin: 0.5em
+ padding-left: 0
+ max-width: 12em
+ list-style: none
+
+.item
+ padding: 0.5em
+ background-color: $space
+ box-shadow: 0 0 0.3em 0.03em rgba(0, 0, 0, 0.3)
+ margin-left: 1em
+ &:hover
+ box-shadow: 0 0 0.3em 0.03em rgba(0, 0, 0, 0.7)
+ transition: .6s
+.hr
+ width: 100%
+ margin-left: 0.5em
+
+.add-list, .list-add-item button
+ cursor: pointer
+ background-color: #fdfdfd
+ border: 1px solid #ccc
+
+///////////////////////////////////////////
+// Button
+///////////////////////////////////////////
+
+.edit-item, .delete-item
+ margin-left: 23%
+
+///////////////////////////////////////////
+// Check
+///////////////////////////////////////////
+
+.checked
+ background-color: $silver
+ text-decoration: line-through
diff --git a/app/stylesheets/components/list.sass b/app/stylesheets/components/list.sass
new file mode 100644
index 0000000..bb7ab67
--- /dev/null
+++ b/app/stylesheets/components/list.sass
@@ -0,0 +1,51 @@
+/////////////////////////////
+// List
+/////////////////////////////
+
+.list
+ display: inline-block
+ margin: 1em
+ border: 1px solid $silver
+ border-radius: $border-radius
+ min-width: 10em
+ vertical-align: top
+
+.header-wrapper
+ background-color: $burlywood
+ .list-header
+ overflow: auto
+ padding: 1em
+ color: $color-secondary
+
+.list-title, .list-add-item-wrapper
+ float: left
+
+.list-add-item-wrapper
+ margin: 1em 0em 0em 0.5em
+
+/////////////////////////////
+// Button
+/////////////////////////////
+
+.add-list
+ height: 5.1em
+ margin: -6em 0em 0em 1.5em
+ float: left
+
+.list-add-item-button, .list-delete button
+ height: 2em
+ width: 2em
+ border: none
+
+.list-delete
+ float: right
+ margin-left: 1em
+ visibility: hidden
+
+.list-delete button
+ cursor: pointer
+ color: $color-primary
+ background-color: $color-subaccent
+
+.list-header:hover .list-delete
+ visibility: visible
diff --git a/app/stylesheets/components/modal.sass b/app/stylesheets/components/modal.sass
new file mode 100644
index 0000000..6265bd3
--- /dev/null
+++ b/app/stylesheets/components/modal.sass
@@ -0,0 +1,25 @@
+////////////////////////////
+// Modal
+////////////////////////////
+
+.modalIsOpen
+ background: $white
+ box-shadow: 0 0 12px 0 rgba(0,0,0,.2)
+ top: 0
+ right: 0
+ bottom: 0
+ left: 0
+ margin: 25% 0% 0% 20%
+ padding: 2em
+ width: 55%
+ height: 30%
+ position: absolute
+ @media #{$medium-up}
+ margin: 25% 0% 0% 39%
+ width: 25%
+ height: 30%
+ @media #{$large-up}
+ margin: 10% 0% 0% 39%
+
+.modalIsClosed
+ display: none
diff --git a/app/stylesheets/components/nav.sass b/app/stylesheets/components/nav.sass
new file mode 100644
index 0000000..ac738d5
--- /dev/null
+++ b/app/stylesheets/components/nav.sass
@@ -0,0 +1,13 @@
+///////////////////////////
+// Nav
+///////////////////////////
+
+.nav-wrapper
+ background: $light-blue
+ width: 100%
+
+.header
+ padding-top: 0.2em
+ height: 1.9em
+ font-family: $font-family-header
+ text-align: center
diff --git a/app/stylesheets/generic/reset.sass b/app/stylesheets/generic/reset.sass
new file mode 100644
index 0000000..e01bc2c
--- /dev/null
+++ b/app/stylesheets/generic/reset.sass
@@ -0,0 +1,35 @@
+// http://meyerweb.com/eric/tools/css/reset/
+// v2.0 | 20110126
+// License: none (public domain)
+*
+ box-sizing: $box-sizing
+
+html, body, div, span, applet, object, iframe, h1, h2, h3, h4, h5, h6, p, blockquote, pre, a, abbr, acronym, address, big, cite, code, del, dfn, em, img, ins, kbd, q, s, samp, small, strike, strong, sub, sup, tt, var, b, u, i, center, dl, dt, dd, ol, ul, li, fieldset, form, label, legend, table, caption, tbody, tfoot, thead, tr, th, td, article, aside, canvas, details, embed, figure, figcaption, footer, header, hgroup, menu, nav, output, ruby, section, summary, time, mark, audio, video
+ border: 0
+ font: inherit
+ size: 100%
+ margin: 0
+ padding: 0
+ text-decoration: none
+ vertical-align: baseline
+
+// HTML5 display-role reset for older browsers
+article, aside, details, figcaption, figure, footer, header, hgroup, menu, nav, section
+ display: block
+
+body
+ line-height: 1
+
+ol, ul
+ list-style: none
+
+blockquote, q
+ quotes: none
+ &:before, &:after
+ content: ''
+ content: none
+
+table
+ border:
+ collapse: collapse
+ spacing: 0
diff --git a/app/stylesheets/main.sass b/app/stylesheets/main.sass
new file mode 100644
index 0000000..f0d2c50
--- /dev/null
+++ b/app/stylesheets/main.sass
@@ -0,0 +1,14 @@
+@import './settings/global'
+@import './settings/colors'
+@import './settings/borders'
+@import './settings/type'
+@import './settings/media-queries'
+@import './generic/reset'
+@import './base/layout'
+@import './base/scale'
+@import './base/type'
+@import './components/nav'
+@import './components/list'
+@import './components/item'
+@import './components/modal'
+@import './components/form'
diff --git a/app/stylesheets/settings/borders.sass b/app/stylesheets/settings/borders.sass
new file mode 100644
index 0000000..744517a
--- /dev/null
+++ b/app/stylesheets/settings/borders.sass
@@ -0,0 +1,6 @@
+////////////////////
+// Border Radius
+////////////////////
+
+$border-radius: 4px !default
+$border-radius-round: 2000px !default
diff --git a/app/stylesheets/settings/colors.sass b/app/stylesheets/settings/colors.sass
new file mode 100644
index 0000000..7a7bd42
--- /dev/null
+++ b/app/stylesheets/settings/colors.sass
@@ -0,0 +1,33 @@
+////////////////////
+// Primary Colors
+////////////////////
+
+$black: rgb(0, 0, 0) !default
+$grey: rgb(117, 117, 117) !default
+$dark-grey: rgb(80, 78, 76) !default
+$silver: rgb(199, 199, 199) !default
+$dark-silver: rgb(149, 149, 149) !default
+$space: rgb(253, 253, 523) !default
+$white: rgb(255, 255, 255) !default
+$burlywood: rgb(239, 220, 195) !default
+$light-blue: rgb(173, 216, 230) !default
+$red: rgb(215, 95, 95) !default
+$deep-salmon: rgb(244, 164, 96) !default
+$cornsilk: rgb(255, 248, 220) !default
+
+// This allows all of the primary colors to be accessed by mixins that generate display classes for objects with one of each color
+$all-colors: (black, $black) (grey, $grey) (dark-grey, $dark-grey) (silver, $silver) (dark-silver, $dark-silver) (space, $space) (white, $white) (burlywood, $burlywood) (light-blue, $light-blue) (red, $red)(deep-salmon, $deep-salmon) (cornsilk, $cornsilk)
+
+////////////////////
+// Function Colors
+////////////////////
+
+$color-primary: $black !default
+$color-inverted: $white !default
+$color-secondary: $dark-grey !default
+$color-accent: $grey !default
+$color-subaccent: $silver !default
+$color-alert: $red !default
+
+// This allows all of the function colors to be accessed by mixins that generate display classes for objects with one of each color
+$all-function-colors: (primary, $color-primary) (secondary, $color-secondary) (accent, $color-accent) (subaccent, $color-subaccent) (alert, $color-alert)
diff --git a/app/stylesheets/settings/global.sass b/app/stylesheets/settings/global.sass
new file mode 100644
index 0000000..0496775
--- /dev/null
+++ b/app/stylesheets/settings/global.sass
@@ -0,0 +1,41 @@
+////////////////////
+// Set Max Width
+////////////////////
+
+$max-width: 120.5rem
+
+////////////////////
+// Set REM Default
+////////////////////
+
+$base-rem: 16px !default
+
+////////////////////
+// Set REM Modifiers
+////////////////////
+
+$small-rem: 14px !default
+$large-rem: 18px !default
+$xlarge-rem: 20px !default
+$xxlarge-rem: 22px !default
+
+////////////////////
+// Set Vertical/Horizontal Rhythm
+////////////////////
+
+$rhythm-vertical: 1.5em !default
+$rhythm-horizontal: 1.125em !default
+
+$rhythm: $rhythm-vertical $rhythm-horizontal !default
+
+////////////////////
+// Set Box-Sizing
+////////////////////
+
+$box-sizing: border-box !default
+
+////////////////////
+// Set Button Naming Convention
+////////////////////
+
+$button-name: button !default
diff --git a/app/stylesheets/settings/media-queries.sass b/app/stylesheets/settings/media-queries.sass
new file mode 100644
index 0000000..d737ab5
--- /dev/null
+++ b/app/stylesheets/settings/media-queries.sass
@@ -0,0 +1,24 @@
+@import './global'
+////////////////////
+// Media Breakpoints
+////////////////////
+
+$breakpoint-small: 37.5em !default
+$breakpoint-medium: 37.5em !default
+$breakpoint-large: 60em !default
+$breakpoint-xlarge: 90.25em !default
+$breakpoint-xxlarge: 100em !default
+$breakpoint-max-width: $max-width !default
+
+////////////////////
+// Media Queries
+////////////////////
+
+$screen: "only screen"
+
+$small-only: "#{$screen} and (max-width: #{$breakpoint-small})"
+$medium-up: "#{$screen} and (min-width: #{$breakpoint-medium})"
+$large-up: "#{$screen} and (min-width: #{$breakpoint-large})"
+$xlarge-up: "#{$screen} and (min-width: #{$breakpoint-xlarge})"
+$xxlarge-up: "#{$screen} and (min-width: #{$breakpoint-xxlarge})"
+$max-width-up: "#{$screen} and (min-width: #{$breakpoint-max-width})"
diff --git a/app/stylesheets/settings/type.sass b/app/stylesheets/settings/type.sass
new file mode 100644
index 0000000..0e98611
--- /dev/null
+++ b/app/stylesheets/settings/type.sass
@@ -0,0 +1,30 @@
+////////////////////
+// Default Font Size/Spacing
+////////////////////
+
+$font-size: 100% !default
+
+$h1-size: 300% !default
+
+$line-height: 1.5 !default
+
+////////////////////
+// Font Families
+////////////////////
+
+@import url(https://fonts.googleapis.com/css?family=Indie+Flower)
+@import url(http://fonts.googleapis.com/css?family=Oswald:400,300)
+@import url(http://fonts.googleapis.com/css?family=Rokkitt:400,700|Source+Sans+Pro:300,400,600,700,800)
+$font-family: "Oswald", "Source Sans Pro", Helvetica, "Helvetica Neue", Arial, sans-serif !default
+$font-family-secondary: "Source Sans Pro", "Rokkitt", Helvetica, "Helvetica Neue", Arial, sans-serif
+$font-family-monospace: Consolas, "Andale Mono WT", "Andale Mono", "Lucida Console", "Lucida Sans Typewriter", "DejaVu Sans Mono", "Bitstream Vera Sans Mono", "Liberation Mono", "Nimbus Mono L", Monaco, "Courier New", Courier, monospace
+$font-family-header: "Indie Flower", "Source Sans Pro", Helvetica
+
+$font-weight-normal: 400 !default
+$font-weight: $font-weight-normal !default
+
+////////////////////
+// Font Colors
+////////////////////
+
+$font-color: $color-primary !default
diff --git a/app/validators/validate.js b/app/validators/validate.js
new file mode 100644
index 0000000..4020356
--- /dev/null
+++ b/app/validators/validate.js
@@ -0,0 +1,29 @@
+const validate = (values) => {
+ const errors = {}
+ const validateSKU = new RegExp('^[a-z0-9A-Z]{10,20}$')
+ const validatePrice = /^(?=.*[1-9])\d{0,5}(\.\d{1,2})?$/
+
+ if (!values.sku || values.sku.trim() === '') {
+ errors.sku = 'Enter a SKU'
+ } else if (validateSKU.test(values.sku)) {
+ errors.sku = 'Invalid SKU'
+ } else if (values.sku.length < 8) {
+ errors.sku = 'SKU is too short'
+ } else if (values.sku.length > 18) {
+ errors.sku = "SKU is too long"
+ }
+ if (!values.text || values.text.trim() === '') {
+ errors.text = 'Enter an item'
+ } else if (values.text.length > 20) {
+ errors.text = 'Item is too long'
+ }
+ if (!values.price || values.price.trim() === '') {
+ errors.price = 'Enter a price'
+ } else if (!validatePrice.test(values.price)) {
+ errors.price = 'Invalid price'
+ }
+
+ return errors
+}
+
+export default validate
diff --git a/package.json b/package.json
index f364602..300130a 100644
--- a/package.json
+++ b/package.json
@@ -5,7 +5,9 @@
"main": "index.js",
"scripts": {
"build": "webpack",
- "start": "webpack-dev-server"
+ "start": "webpack-dev-server",
+ "test": "mocha --compilers js:babel-register --recursive --require test/helpers/test_setup.js",
+ "test:watch": "npm test -- --watch"
},
"keywords": [
"webpack",
@@ -14,21 +16,43 @@
"author": "",
"license": "MIT",
"devDependencies": {
+ "assert": "^1.3.0",
"babel-core": "^6.5.2",
"babel-loader": "^6.2.2",
+ "babel-polyfill": "^6.7.4",
"babel-preset-es2015": "^6.5.0",
"babel-preset-react": "^6.5.0",
"babel-preset-react-hmre": "^1.1.0",
"babel-preset-survivejs-kanban": "^0.3.3",
+ "babel-register": "^6.7.2",
+ "chai": "^3.5.0",
"css-loader": "^0.23.1",
+ "ejs-loader": "^0.2.1",
+ "enzyme": "^2.2.0",
+ "file-loader": "^0.8.5",
+ "image-webpack-loader": "^1.7.0",
+ "mocha": "^2.4.5",
+ "node-sass": "^3.4.2",
"npm-install-webpack-plugin": "^2.0.2",
+ "react-addons-test-utils": "^0.14.8",
+ "sass-loader": "^3.2.0",
+ "sinon": "^1.17.3",
"style-loader": "^0.13.0",
"webpack": "^1.12.13",
"webpack-dev-server": "^1.14.1",
"webpack-merge": "^0.7.3"
},
"dependencies": {
+ "file-loader": "^0.8.5",
+ "node-uuid": "^1.4.7",
"react": "^0.14.7",
- "react-dom": "^0.14.7"
+ "react-dom": "^0.14.7",
+ "react-redux": "^4.4.1",
+ "redux": "^3.3.1",
+ "redux-form": "^5.0.1",
+ "redux-logger": "^2.6.1",
+ "redux-thunk": "^2.0.1",
+ "resolve-url-loader": "^1.4.3",
+ "url-loader": "^0.5.7"
}
}
diff --git a/test/helpers/test_setup.js b/test/helpers/test_setup.js
new file mode 100644
index 0000000..cfa8bad
--- /dev/null
+++ b/test/helpers/test_setup.js
@@ -0,0 +1,15 @@
+import 'babel-register'
+import { jsdom } from 'jsdom'
+import 'babel-polyfill'
+import sinon from 'sinon'
+import expect from 'expect'
+import { render, shallow, mount } from 'enzyme'
+
+global.document = require('jsdom').jsdom('')
+global.window = document.defaultView
+global.navigator = window.navigator
+global.expect = expect
+global.sinon = sinon
+global.shallow = shallow
+global.mount = mount
+global.render = render
diff --git a/test/item_form.spec.js b/test/item_form.spec.js
new file mode 100644
index 0000000..0c8d83a
--- /dev/null
+++ b/test/item_form.spec.js
@@ -0,0 +1,72 @@
+import React from 'react'
+import validate from '../app/validators/validate'
+
+describe('validate', () => {
+ it('should validate item', () => {
+ const validItem = { sku: '00000000', text: 'This text', price: '1.00' }
+ const message = validate(validItem)
+
+ expect(message).toEqual({})
+ })
+
+ it('should return Enter a SKU error', () => {
+ const item = { sku: '', text: 'text', price: '1.00'}
+ const message = validate(item)
+
+ expect(message).toEqual({ sku: 'Enter a SKU'})
+ })
+
+ it('should return sku is too short error', () => {
+ const item = { sku: '00', text: 'text', price: '1.00'}
+ const message = validate(item)
+
+ expect(message).toEqual({ sku: 'SKU is too short'})
+ })
+
+ it('should return sku is too long error', () => {
+ const item = { sku: '0000000000000000000000000', text: 'text', price: '1.00'}
+ const message = validate(item)
+
+ expect(message).toEqual({ sku: 'SKU is too long'})
+ })
+
+ it('should return Invalid SKU error', () => {
+ const item = { sku: 'asdfasdfasdfaa', text: 'text', price: '1.00'}
+ const message = validate(item)
+
+ expect(message).toEqual({ sku: 'Invalid SKU'})
+ })
+
+ it('should return Enter an item error', () => {
+ const item = { sku: '00000000', text: '', price: '1.00'}
+ const message = validate(item)
+
+ expect(message).toEqual({ text: 'Enter an item'})
+ })
+
+ it('should return Item is too long error', () => {
+ const item = {
+ sku: '00000000',
+ text: 'This is a long text more than 20 characters for sure',
+ price: '1.00'
+ }
+
+ const message = validate(item)
+
+ expect(message).toEqual({ text: 'Item is too long'})
+ })
+
+ it('should return Enter a price error', () => {
+ const item = { sku: '00000000', text: 'text', price: ''}
+ const message = validate(item)
+
+ expect(message).toEqual({ price: 'Enter a price'})
+ })
+
+ it('should return Invalid price error', () => {
+ const item = { sku: '00000000', text: 'text', price: 'This'}
+ const message = validate(item)
+
+ expect(message).toEqual({ price: 'Invalid price'})
+ })
+})
diff --git a/test/item_reducer.spec.js b/test/item_reducer.spec.js
new file mode 100644
index 0000000..5637232
--- /dev/null
+++ b/test/item_reducer.spec.js
@@ -0,0 +1,59 @@
+import reducer from '../app/reducers/items'
+import * as ActionType from '../app/actions/items'
+
+describe('Item Reducers', () => {
+ const initialState = [{
+ id: 0,
+ sku: "00000000",
+ text: "Initial State",
+ price: "1.00",
+ checked: false
+ }]
+
+ const createdItem = reducer(undefined, {
+ type: ActionType.CREATE_ITEM,
+ item: initialState[0]
+ })
+
+ it('should return initial state', () => {
+ expect(
+ reducer(undefined, {})
+ ).toEqual({})
+ })
+
+ it('should handle CREATE_ITEM', () => {
+ expect(createdItem).toEqual(
+ [
+ {
+ id: 0,
+ sku: "00000000",
+ text: "Initial State",
+ price: "1.00",
+ checked: false
+ }
+ ]
+ )
+ })
+
+ it('should handle UPDATE_ITEM', () => {
+ let item = {
+ id: 0,
+ sku: "11111111",
+ text: "updated",
+ price: "2.00",
+ checked: false
+ }
+
+ const updatedItem = reducer(createdItem, {
+ type: ActionType.UPDATE_ITEM,
+ updatedItem: item
+ })
+
+ expect(updatedItem).toEqual([ item ])
+ })
+
+ it('should handle DELETE_ITEM', () => {
+ const nextState = reducer(createdItem.id, "DELETE_ITEM")
+ expect(nextState).toEqual({})
+ })
+})
diff --git a/test/list_reducer.spec.js b/test/list_reducer.spec.js
new file mode 100644
index 0000000..7643c2a
--- /dev/null
+++ b/test/list_reducer.spec.js
@@ -0,0 +1,70 @@
+import reducer from '../app/reducers/lists'
+
+describe('List Reducers', () => {
+ const initialState = [{
+ id: 0,
+ title: 'New Shopping List',
+ items: []
+ }]
+
+ const createdList = reducer(undefined, {
+ type: 'CREATE_LIST',
+ list: initialState[0],
+ })
+
+ it('should return initial state', () => {
+ expect(reducer(undefined, {})).toEqual([])
+ })
+
+ it('should handle CREATE_LIST', () => {
+ expect(createdList).toEqual([
+ {
+ id: 0,
+ title: 'New Shopping List',
+ items: []
+ }
+ ])
+ })
+
+ it('should handle UPDATE_LIST', () => {
+ const list = { title: 'Updated Title' }
+
+ const newState = reducer(createdList, {
+ type: 'UPDATE_LIST',
+ id: 0,
+ title: list.title
+ })
+
+ expect(newState[0].title).toMatch(/Updated Title/)
+ })
+
+ it('should handle DELETE_LIST', () => {
+ const nextState = reducer(createdList.id, 'DELETE_LIST')
+
+ expect(nextState).toEqual([])
+ })
+
+ it('should handle CONNECT_TO_LIST', () => {
+ let item = { id: '12345' }
+
+ const newState = reducer(createdList, {
+ type: 'CONNECT_TO_LIST',
+ listId: createdList[0].id,
+ itemId: item.id
+ })
+
+ expect(newState[0].items.length).toEqual(1)
+ })
+
+ it('should handle DISCONNECT_FROM_LIST', () => {
+ let item = { id: '12345' }
+
+ const newState = reducer(createdList, {
+ type: 'DISCONNECT_FROM_LIST',
+ listId: createdList[0].id,
+ itemId: item.id
+ })
+
+ expect(newState[0].items.length).toEqual(0)
+ })
+})
diff --git a/webpack.config.js b/webpack.config.js
index 797ff02..688edd0 100644
--- a/webpack.config.js
+++ b/webpack.config.js
@@ -2,6 +2,7 @@ const path = require('path');
const merge = require('webpack-merge');
const webpack = require('webpack');
const NpmInstallPlugin = require('npm-install-webpack-plugin');
+const ExtractTextPlugin = require('extract-text-webpack-plugin')
const TARGET = process.env.npm_lifecycle_event;
const PATHS = {
@@ -25,11 +26,13 @@ const common = {
module: {
loaders: [
{
+ // css loader
test: /\.css$/,
- loaders: ['style', 'css'],
+ loaders: ['style', 'css', 'resolve-url'],
include: PATHS.app
},
{
+ // jsx loader
test: /\.jsx?$/,
loaders: ['babel?cacheDirectory'],
include: PATHS.app
@@ -57,8 +60,20 @@ if(TARGET === 'start' || !TARGET) {
host: process.env.HOST,
port: process.env.PORT
},
+ module: {
+ loaders: [
+ // sass loader
+ { test: /\.(scss|sass)$/, loaders: ['style', 'css', 'resolve-url', 'sass?sourceMap'], include: PATHS.style },
+ // css loader
+ { test: /\.css$/, loader: 'style-loader!css-loader' },
+ // image loader
+ { test: /\.(png|jpg)$/, loader: 'file-loader?name=images/[name].[ext]' },
+ { test: /\.html$/, loader: 'html-loader' }
+ ]
+ },
plugins: [
new webpack.HotModuleReplacementPlugin(),
+ new ExtractTextPlugin('[name].css'),
new NpmInstallPlugin({
save: true // --save
})