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 ( +
    +
    + + +
    {sku.touched ? sku.error : ''}
    +
    + +
    + + +
    {text.touched ? text.error : ''}
    +
    + +
    + + +
    {price.touched ? price.error : ''}
    +
    + { id && id.value ? + : + + } + +
    + ) + } +} + +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 ( + + ) + } +} + +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 })