├── src ├── theme │ └── .gitkeep ├── static │ └── .gitkeep ├── utils │ ├── partial.js │ ├── compose.js │ ├── pacomo.js │ ├── uuid.js │ ├── compact.js │ ├── typeReducers.js │ └── defineActionTypes.js ├── actors │ ├── index.js │ ├── renderer.js │ └── redirector.js ├── constants │ ├── ROUTES.js │ └── ACTION_TYPES.js ├── actions │ ├── documentListView.js │ ├── navigation.js │ └── documentView.js ├── validators │ └── documentValidator.js ├── reducers │ ├── view │ │ ├── documentListViewReducer.js │ │ └── documentViewReducer.js │ ├── data │ │ └── documentDataReducer.js │ ├── navigationReducer.js │ └── index.js ├── components │ ├── DocumentForm.less │ ├── Link.jsx │ ├── OneOrTwoColumnLayout.less │ ├── ApplicationLayout.less │ ├── DocumentList.less │ ├── OneOrTwoColumnLayout.jsx │ ├── ApplicationLayout.jsx │ ├── DocumentList.jsx │ └── DocumentForm.jsx ├── index.html ├── containers │ ├── DocumentContainer.jsx │ └── DocumentListContainer.jsx ├── main.less ├── Application.jsx └── main.js ├── .babelrc ├── .gitignore ├── config └── environments │ ├── development.js │ └── production.js ├── package.json ├── webpack.config.js ├── gulpfile.babel.js └── README.md /src/theme/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/static/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["es2015"] 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | build 3 | dist 4 | dist-intermediate 5 | -------------------------------------------------------------------------------- /config/environments/development.js: -------------------------------------------------------------------------------- 1 | export default { 2 | identityProperty: 'APP_IDENTITY', 3 | } 4 | -------------------------------------------------------------------------------- /config/environments/production.js: -------------------------------------------------------------------------------- 1 | export default { 2 | identityProperty: 'APP_IDENTITY', 3 | } 4 | -------------------------------------------------------------------------------- /src/utils/partial.js: -------------------------------------------------------------------------------- 1 | export default function partial(fn, ...firstArgs) { 2 | return (...args) => fn(...firstArgs, ...args) 3 | } 4 | -------------------------------------------------------------------------------- /src/actors/index.js: -------------------------------------------------------------------------------- 1 | import redirector from './redirector' 2 | import renderer from './renderer' 3 | 4 | export default [ 5 | redirector, 6 | renderer, 7 | ] 8 | -------------------------------------------------------------------------------- /src/constants/ROUTES.js: -------------------------------------------------------------------------------- 1 | import uniloc from 'uniloc' 2 | 3 | export default uniloc({ 4 | root: 'GET /', 5 | documentList: 'GET /documents', 6 | documentEdit: 'GET /documents/:id', 7 | }) 8 | -------------------------------------------------------------------------------- /src/utils/compose.js: -------------------------------------------------------------------------------- 1 | export default function compose(...funcs) { 2 | const innerFunc = funcs.pop() 3 | return (...args) => funcs.reduceRight((composed, f) => f(composed), innerFunc(...args)) 4 | } 5 | -------------------------------------------------------------------------------- /src/actions/documentListView.js: -------------------------------------------------------------------------------- 1 | import T from '../constants/ACTION_TYPES' 2 | 3 | 4 | export function updateQuery(query) { 5 | return { 6 | type: T.DOCUMENT_LIST_VIEW.SET_QUERY, 7 | query, 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/utils/pacomo.js: -------------------------------------------------------------------------------- 1 | import { withPackageName } from 'react-pacomo' 2 | 3 | 4 | const { 5 | decorator: pacomoDecorator, 6 | transformer: pacomoTransformer, 7 | } = withPackageName('app') 8 | 9 | 10 | export {pacomoTransformer, pacomoDecorator} 11 | -------------------------------------------------------------------------------- /src/validators/documentValidator.js: -------------------------------------------------------------------------------- 1 | import compact from '../utils/compact' 2 | 3 | 4 | export default function documentValidator(data) { 5 | return compact({ 6 | titleExists: 7 | !data.title && 8 | "You must specify a title for your document", 9 | }) 10 | } 11 | -------------------------------------------------------------------------------- /src/utils/uuid.js: -------------------------------------------------------------------------------- 1 | function uuidReplacer(c) { 2 | const r = Math.random()*16|0 3 | const v = c == 'x' ? r : (r&0x3|0x8) 4 | return v.toString(16) 5 | } 6 | 7 | 8 | export default function uuid() { 9 | return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, uuidReplacer) 10 | } 11 | -------------------------------------------------------------------------------- /src/reducers/view/documentListViewReducer.js: -------------------------------------------------------------------------------- 1 | import typeReducers from '../../utils/typeReducers' 2 | import ACTION_TYPES from '../../constants/ACTION_TYPES' 3 | 4 | 5 | const defaultState = '' 6 | 7 | 8 | export default typeReducers(ACTION_TYPES.DOCUMENT_LIST_VIEW, defaultState, { 9 | SET_QUERY: (state, {query}) => query, 10 | }) 11 | -------------------------------------------------------------------------------- /src/utils/compact.js: -------------------------------------------------------------------------------- 1 | export default function compact(obj) { 2 | let entries = Object.entries(obj) 3 | let result = Object.assign({}, obj) 4 | let count = entries.length 5 | for (let [key, value] of entries) { 6 | if (!value) { 7 | count -= 1 8 | delete result[key] 9 | } 10 | } 11 | return count === 0 ? null : result 12 | } 13 | -------------------------------------------------------------------------------- /src/reducers/data/documentDataReducer.js: -------------------------------------------------------------------------------- 1 | import typeReducers from '../../utils/typeReducers' 2 | import ACTION_TYPES from '../../constants/ACTION_TYPES' 3 | 4 | 5 | const defaultState = {} 6 | 7 | 8 | export default typeReducers(ACTION_TYPES.DOCUMENT_DATA, defaultState, { 9 | UPDATE: (state, {id, data}) => ({ 10 | ...state, 11 | [id]: { ...state[id], ...data }, 12 | }) 13 | }) 14 | -------------------------------------------------------------------------------- /src/utils/typeReducers.js: -------------------------------------------------------------------------------- 1 | export default function typeReducers(actionTypes, defaultState, reducers) { 2 | const inverseActionTypes = 3 | new Map(Object.entries(actionTypes).map(([x, y]) => [y, x])) 4 | 5 | return (state = defaultState, action) => { 6 | const reducer = reducers[inverseActionTypes.get(action.type)] 7 | return reducer ? reducer(state, action) : state 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/components/DocumentForm.less: -------------------------------------------------------------------------------- 1 | .app-DocumentForm { 2 | width: 100%; 3 | padding: 5px; 4 | 5 | &-errors { 6 | list-style: none; 7 | padding: 0; 8 | margin: 0; 9 | } 10 | &-error { 11 | color: red; 12 | margin-bottom: 5px; 13 | } 14 | 15 | &-title, 16 | &-content { 17 | display: block; 18 | width: 100%; 19 | margin-bottom: 5px; 20 | } 21 | 22 | &-cancel { 23 | margin-right: 5px; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/components/Link.jsx: -------------------------------------------------------------------------------- 1 | import React, {PropTypes} from 'react' 2 | import ROUTES from '../constants/ROUTES' 3 | 4 | 5 | const Link = ({ 6 | name, 7 | options, 8 | children, 9 | ...props 10 | }) => 11 | {children} 12 | 13 | Link.propTypes = { 14 | name: PropTypes.string.isRequired, 15 | options: PropTypes.object, 16 | children: PropTypes.node.isRequired, 17 | } 18 | 19 | export default Link 20 | -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Unicorn Standard Boilerplate 8 | 9 | 10 | 11 | 12 | 13 |
14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /src/reducers/navigationReducer.js: -------------------------------------------------------------------------------- 1 | import typeReducers from '../utils/typeReducers' 2 | import ACTION_TYPES from '../constants/ACTION_TYPES' 3 | 4 | 5 | const defaultState = { 6 | transitioning: true, 7 | location: null, 8 | } 9 | 10 | export default typeReducers(ACTION_TYPES.NAVIGATION, defaultState, { 11 | START: () => ({ 12 | transitioning: true, 13 | }), 14 | 15 | COMPLETE: (state, {location}) => ({ 16 | transitioning: false, 17 | location, 18 | }), 19 | }) 20 | -------------------------------------------------------------------------------- /src/components/OneOrTwoColumnLayout.less: -------------------------------------------------------------------------------- 1 | .app-OneOrTwoColumnLayout { 2 | position: relative; 3 | height: 100%; 4 | z-index: 1; 5 | 6 | &-left { 7 | position: absolute; 8 | left: 0; 9 | width: 50%; 10 | height: 100%; 11 | overflow: hidden; 12 | } 13 | 14 | &-right { 15 | position: absolute; 16 | right: 0; 17 | width: 50%; 18 | height: 100%; 19 | overflow: hidden; 20 | } 21 | &-right-open { 22 | border-left: 1px solid black; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/components/ApplicationLayout.less: -------------------------------------------------------------------------------- 1 | .app-ApplicationLayout { 2 | position: relative; 3 | height: 100%; 4 | 5 | &-navbar { 6 | position: fixed; 7 | left: 0; 8 | top: 0; 9 | bottom: 0; 10 | width: 192px; 11 | 12 | border-right: 1px solid black; 13 | z-index: 2; 14 | } 15 | 16 | &-content { 17 | position: relative; 18 | padding-left: 192px; 19 | width: 100%; 20 | height: 100%; 21 | } 22 | 23 | &-link-active { 24 | font-weight: bold; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/reducers/index.js: -------------------------------------------------------------------------------- 1 | import { combineReducers } from 'redux' 2 | 3 | 4 | import navigation from './navigationReducer' 5 | 6 | import documentListView from './view/documentListViewReducer' 7 | import documentView from './view/documentViewReducer' 8 | 9 | import documentData from './data/documentDataReducer' 10 | 11 | 12 | export default combineReducers({ 13 | navigation, 14 | view: combineReducers({ 15 | documentList: documentListView, 16 | document: documentView, 17 | }), 18 | data: combineReducers({ 19 | document: documentData, 20 | }), 21 | }) 22 | -------------------------------------------------------------------------------- /src/components/DocumentList.less: -------------------------------------------------------------------------------- 1 | .app-DocumentList { 2 | width: 100%; 3 | 4 | &-header { 5 | width: 100%; 6 | padding: 5px; 7 | } 8 | 9 | &-query { 10 | width: 100%; 11 | } 12 | 13 | &-list { 14 | list-style: none; 15 | margin: 0; 16 | padding: 0; 17 | } 18 | 19 | &-document-item-active, 20 | &-add-item-active { 21 | background-color: #f0f0f0; 22 | } 23 | 24 | &-document-link, 25 | &-add-link { 26 | display: block; 27 | padding: 5px; 28 | 29 | &:hover { 30 | background-color: #f8f8f8; 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/constants/ACTION_TYPES.js: -------------------------------------------------------------------------------- 1 | import defineActionTypes from '../utils/defineActionTypes' 2 | 3 | export default defineActionTypes({ 4 | /* 5 | * View model 6 | */ 7 | 8 | DOCUMENT_LIST_VIEW: ` 9 | SET_QUERY 10 | `, 11 | 12 | DOCUMENT_VIEW: ` 13 | UPDATE_DATA 14 | SET_ERRORS 15 | REMOVE_STALE_ERRORS 16 | CLEAR 17 | `, 18 | 19 | 20 | /* 21 | * Data model 22 | */ 23 | 24 | DOCUMENT_DATA: ` 25 | UPDATE 26 | `, 27 | 28 | /* 29 | * Application 30 | */ 31 | 32 | NAVIGATION: ` 33 | START 34 | COMPLETE 35 | `, 36 | }) 37 | -------------------------------------------------------------------------------- /src/actors/renderer.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom' 3 | import Application from '../Application' 4 | 5 | 6 | // Store a reference to our application's root DOM node to prevent repeating 7 | // this on every state update 8 | const APP_NODE = document.getElementById('react-app') 9 | 10 | export default function renderer(state, dispatch) { 11 | // Don't re-render if we're in the process of navigating to a new page 12 | if (!state.navigation.transitioning) { 13 | ReactDOM.render( 14 | , 15 | APP_NODE 16 | ) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/components/OneOrTwoColumnLayout.jsx: -------------------------------------------------------------------------------- 1 | import './OneOrTwoColumnLayout.less' 2 | 3 | import React, {PropTypes} from 'react' 4 | import { pacomoTransformer } from '../utils/pacomo' 5 | 6 | 7 | const OneOrTwoColumnLayout = ({ 8 | left, 9 | right, 10 | }) => 11 |
12 |
13 | {left} 14 |
15 |
16 | {right} 17 |
18 |
19 | 20 | OneOrTwoColumnLayout.propTypes = { 21 | left: PropTypes.element, 22 | right: PropTypes.element, 23 | } 24 | 25 | export default pacomoTransformer(OneOrTwoColumnLayout) 26 | -------------------------------------------------------------------------------- /src/actions/navigation.js: -------------------------------------------------------------------------------- 1 | import T from '../constants/ACTION_TYPES' 2 | import ROUTES from '../constants/ROUTES' 3 | 4 | 5 | // `navigate` is used to facilitate changing routes within another action 6 | // without rendering any other changes first 7 | export function start(name, options) { 8 | return dispatch => { 9 | const currentURI = window.location.hash.substr(1) 10 | const newURI = ROUTES.generate(name, options) 11 | 12 | if (currentURI != newURI) { 13 | dispatch({ 14 | type: T.NAVIGATION.START, 15 | }) 16 | 17 | window.location.replace( 18 | window.location.pathname + window.location.search + '#' + newURI 19 | ) 20 | } 21 | } 22 | } 23 | 24 | export function complete() { 25 | return { 26 | type: T.NAVIGATION.COMPLETE, 27 | location: ROUTES.lookup(window.location.hash.substr(1)), 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/actors/redirector.js: -------------------------------------------------------------------------------- 1 | import * as navigation from '../actions/navigation' 2 | import ROUTES from '../constants/ROUTES' 3 | 4 | 5 | export default function redirector(state, dispatch) { 6 | const {name, options} = state.navigation.location || {} 7 | const currentURI = window.location.hash.substr(1) 8 | const canonicalURI = name && ROUTES.generate(name, options) 9 | 10 | if (canonicalURI && canonicalURI !== currentURI) { 11 | // If the URL entered includes extra `/` characters, or otherwise 12 | // differs from the canonical URL, navigate the user to the 13 | // canonical URL (which will result in `complete` being called again) 14 | dispatch(navigation.start(name, options)) 15 | } 16 | else if (name == 'root') { 17 | // If we've hit the root location, redirect the user to the main page 18 | dispatch(navigation.start('documentList')) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/components/ApplicationLayout.jsx: -------------------------------------------------------------------------------- 1 | import './ApplicationLayout.less' 2 | 3 | import React, {PropTypes} from 'react' 4 | import { pacomoTransformer } from '../utils/pacomo' 5 | import Link from './Link' 6 | 7 | 8 | const ApplicationLayout = ({ 9 | children, 10 | locationName, 11 | }) => 12 |
13 | 24 |
25 | {children} 26 |
27 |
28 | 29 | ApplicationLayout.propTypes = { 30 | children: PropTypes.element.isRequired, 31 | locationName: PropTypes.string, 32 | } 33 | 34 | export default pacomoTransformer(ApplicationLayout) 35 | -------------------------------------------------------------------------------- /src/containers/DocumentContainer.jsx: -------------------------------------------------------------------------------- 1 | import React, {PropTypes} from 'react' 2 | import * as actions from '../actions/documentView' 3 | import compose from '../utils/compose' 4 | import partial from '../utils/partial' 5 | import DocumentForm from '../components/DocumentForm' 6 | 7 | 8 | export default function DocumentContainer({state, dispatch, id}) { 9 | const errors = state.view.document.saveErrors[id] 10 | const viewData = state.view.document.unsavedChanges[id] 11 | const data = 12 | viewData || 13 | state.data.document[id] || 14 | (id == 'new' && {}) 15 | const props = { 16 | data, 17 | errors, 18 | onUpdate: compose(dispatch, partial(actions.updateChanges, id)), 19 | onCancel: compose(dispatch, partial(actions.cancelChanges, id)), 20 | onSubmit: 21 | viewData && !errors 22 | ? compose(dispatch, partial(actions.submitChanges, id)) 23 | : null, 24 | } 25 | 26 | return !data ?
Not Found
: 27 | } 28 | -------------------------------------------------------------------------------- /src/containers/DocumentListContainer.jsx: -------------------------------------------------------------------------------- 1 | import React, {PropTypes} from 'react' 2 | import * as actions from '../actions/documentListView' 3 | import compose from '../utils/compose' 4 | import OneOrTwoColumnLayout from '../components/OneOrTwoColumnLayout' 5 | import DocumentList from '../components/DocumentList' 6 | 7 | 8 | function listPredicate(query) { 9 | return ( 10 | !query 11 | ? () => true 12 | : ([id, data]) => data.title.replace(/\s+/g, '').indexOf(query) !== -1 13 | ) 14 | } 15 | 16 | 17 | export default function DocumentListContainer({state, dispatch, children, id}) { 18 | const query = state.view.documentList 19 | const props = { 20 | id, 21 | query, 22 | documents: Object 23 | .entries(state.data.document) 24 | .filter(listPredicate(query)), 25 | onChangeQuery: compose(dispatch, actions.updateQuery), 26 | } 27 | 28 | return } 30 | right={children} 31 | /> 32 | } 33 | -------------------------------------------------------------------------------- /src/main.less: -------------------------------------------------------------------------------- 1 | /* 2 | * This file contains Global styles. 3 | * 4 | * In general, your styles should *not* be in this file, but in the individual 5 | * component files. For details, see the Pacomo specification: 6 | * 7 | * https://github.com/unicorn-standard/pacomo 8 | */ 9 | 10 | @import url('http://fonts.googleapis.com/css?family=Roboto:300,400,500'); 11 | 12 | * { 13 | box-sizing: border-box; 14 | margin: 0; 15 | } 16 | *:before, 17 | *:after { 18 | box-sizing: border-box; 19 | } 20 | 21 | html, body, main { 22 | position: relative; 23 | height: 100%; 24 | min-height: 100%; 25 | font-family: Roboto; 26 | } 27 | 28 | body { 29 | -webkit-tap-highlight-color: rgba(0,0,0,0); 30 | } 31 | 32 | // Reset fonts for relevant elements 33 | input, 34 | button, 35 | select, 36 | textarea { 37 | font-family: inherit; 38 | font-size: inherit; 39 | line-height: inherit; 40 | } 41 | 42 | #react-app { 43 | position: relative; 44 | height: 100%; 45 | min-height: 100%; 46 | } 47 | -------------------------------------------------------------------------------- /src/utils/defineActionTypes.js: -------------------------------------------------------------------------------- 1 | import invariant from 'invariant' 2 | 3 | 4 | export default function defineActionTypes(obj) { 5 | const result = {} 6 | 7 | for (let [namespace, value] of Object.entries(obj)) { 8 | let types = value.trim().split(/\s+/) 9 | const namespaceTypes = {} 10 | 11 | invariant( 12 | /^[A-Z][A-Z0-9_]*$/.test(namespace), 13 | "Namespace names must start with a capital letter, and be composed entirely of capital letters, numbers, and the underscore character." 14 | ) 15 | invariant( 16 | (new Set(types)).size == types.length, 17 | "There must be no repeated action types passed to defineActionTypes" 18 | ) 19 | 20 | for (let type of types) { 21 | invariant( 22 | /^[A-Z][A-Z0-9_]*$/.test(type), 23 | "Types must start with a capital letter, and be composed entirely of capital letters, numbers, and the underscore character." 24 | ) 25 | 26 | namespaceTypes[type] = `@@app/${namespace}/${type}` 27 | } 28 | 29 | result[namespace] = namespaceTypes 30 | } 31 | 32 | return result 33 | } 34 | -------------------------------------------------------------------------------- /src/Application.jsx: -------------------------------------------------------------------------------- 1 | import React, {PropTypes} from 'react' 2 | import ApplicationLayout from './components/ApplicationLayout' 3 | import DocumentContainer from './containers/DocumentContainer' 4 | import DocumentListContainer from './containers/DocumentListContainer' 5 | 6 | 7 | // Application is the root component for your application. 8 | export default function Application(props) { 9 | return ( 10 | 11 | {selectChildContainer(props)} 12 | 13 | ) 14 | } 15 | Application.propTypes = { 16 | state: PropTypes.object.isRequired, 17 | dispatch: PropTypes.func.isRequired, 18 | } 19 | 20 | 21 | // Define this as a separate function to allow us to use the switch statement 22 | // with `return` statements instead of `break` 23 | const selectChildContainer = props => { 24 | const location = props.state.navigation.location 25 | 26 | let child 27 | switch (location.name) { 28 | case 'documentEdit': 29 | child = 30 | case 'documentList': 31 | return {child} 32 | 33 | default: 34 | return "Not Found" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/reducers/view/documentViewReducer.js: -------------------------------------------------------------------------------- 1 | import pick from 'object-pick' 2 | import typeReducers from '../../utils/typeReducers' 3 | import compact from '../../utils/compact' 4 | import ACTION_TYPES from '../../constants/ACTION_TYPES' 5 | 6 | 7 | const defaultState = { 8 | unsavedChanges: {}, 9 | saveErrors: {}, 10 | } 11 | 12 | 13 | export default typeReducers(ACTION_TYPES.DOCUMENT_VIEW, defaultState, { 14 | // Update the current document data 15 | UPDATE_DATA: (state, {id, data}) => ({ 16 | ...state, 17 | unsavedChanges: { 18 | ...state.unsavedChanges, 19 | [id]: { ...state.unsavedChanges[id], ...data }, 20 | }, 21 | }), 22 | 23 | // If there are fields marked as invalid which are now valid, 24 | // mark them as valid 25 | REMOVE_STALE_ERRORS: (state, {id, errors}) => ({ 26 | ...state, 27 | saveErrors: { 28 | ...state.saveErrors, 29 | [id]: compact(pick(state.saveErrors[id], Object.keys(errors || {}))), 30 | }, 31 | }), 32 | 33 | // Set the errors to the passed in object 34 | SET_ERRORS: (state, {id, errors}) => ({ 35 | ...state, 36 | saveErrors: { 37 | ...state.saveErrors, 38 | [id]: errors 39 | }, 40 | }), 41 | 42 | // Remove errors/data for an id 43 | CLEAR: (state, {id}) => ({ 44 | unsavedChanges: { 45 | ...state.unsavedChanges, 46 | [id]: null, 47 | }, 48 | saveErrors: { 49 | ...state.saveErrors, 50 | [id]: null, 51 | }, 52 | }), 53 | }) 54 | -------------------------------------------------------------------------------- /src/actions/documentView.js: -------------------------------------------------------------------------------- 1 | import uuid from '../utils/uuid' 2 | import documentValidator from '../validators/documentValidator' 3 | import T from '../constants/ACTION_TYPES' 4 | import * as navigation from './navigation' 5 | 6 | 7 | export function updateChanges(id, data) { 8 | return [ 9 | { 10 | type: T.DOCUMENT_VIEW.UPDATE_DATA, 11 | id, 12 | data, 13 | }, 14 | { 15 | type: T.DOCUMENT_VIEW.REMOVE_STALE_ERRORS, 16 | id, 17 | errors: documentValidator(data), 18 | }, 19 | ] 20 | } 21 | 22 | export function clearChanges(id) { 23 | return { 24 | type: T.DOCUMENT_VIEW.CLEAR, 25 | id, 26 | } 27 | } 28 | 29 | export function cancelChanges(id) { 30 | return [ 31 | clearChanges(id), 32 | navigation.start('documentList'), 33 | ] 34 | } 35 | 36 | export function submitChanges(id) { 37 | return (dispatch, getState) => { 38 | const { view } = getState() 39 | const data = view.document.unsavedChanges[id] 40 | const errors = documentValidator(data) 41 | 42 | if (errors) { 43 | dispatch({ 44 | type: T.DOCUMENT_VIEW.SET_ERRORS, 45 | id, 46 | errors, 47 | }) 48 | } 49 | else { 50 | const newId = id == 'new' ? uuid() : id 51 | dispatch(navigation.start('documentEdit', {id: newId})) 52 | dispatch(clearChanges(id)) 53 | dispatch({ 54 | type: T.DOCUMENT_DATA.UPDATE, 55 | id: newId, 56 | data, 57 | }) 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/main.js: -------------------------------------------------------------------------------- 1 | import { createStore, applyMiddleware } from 'redux' 2 | import reduxThunk from 'redux-thunk' 3 | import reduxMulti from 'redux-multi' 4 | import { batchedSubscribe } from 'redux-batched-subscribe' 5 | 6 | import * as navigation from './actions/navigation' 7 | import actors from './actors' 8 | import rootReducer from './reducers' 9 | 10 | 11 | // Add middleware to allow our action creators to return functions and arrays 12 | const createStoreWithMiddleware = applyMiddleware( 13 | reduxThunk, 14 | reduxMulti, 15 | )(createStore) 16 | 17 | // Ensure our listeners are only called once, even when one of the above 18 | // middleware call the underlying store's `dispatch` multiple times 19 | const createStoreWithBatching = batchedSubscribe( 20 | fn => fn() 21 | )(createStoreWithMiddleware) 22 | 23 | // Create a store with our application reducer 24 | const store = createStoreWithBatching(rootReducer) 25 | 26 | // Handle changes to our store with a list of actor functions, but ensure 27 | // that the actor sequence cannot be started by a dispatch from an actor 28 | let acting = false 29 | store.subscribe(function() { 30 | if (!acting) { 31 | acting = true 32 | 33 | for (let actor of actors) { 34 | actor(store.getState(), store.dispatch) 35 | } 36 | 37 | acting = false 38 | } 39 | }) 40 | 41 | // Dispatch navigation events when the URL's hash changes, and when the 42 | // application loads 43 | function onHashChange() { 44 | store.dispatch(navigation.complete()) 45 | } 46 | window.addEventListener('hashchange', onHashChange, false) 47 | onHashChange() 48 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "", 3 | "description": "", 4 | "author": "", 5 | "private": true, 6 | "version": "0.1.0", 7 | "license": "MIT", 8 | "main": "static", 9 | "scripts": { 10 | "start": "gulp serve", 11 | "open": "gulp open", 12 | "gulp": "gulp" 13 | }, 14 | "devDependencies": { 15 | "autoprefixer-loader": "^3.2.0", 16 | "babel-core": "^6.1.4", 17 | "babel-loader": "^6.1.0", 18 | "babel-plugin-transform-runtime": "^6.1.4", 19 | "babel-preset-es2015": "^6.1.4", 20 | "babel-preset-react": "^6.1.4", 21 | "babel-preset-stage-0": "^6.1.2", 22 | "babel-register": "^6.1.4", 23 | "css-loader": "^0.23.1", 24 | "del": "^2.2.0", 25 | "extract-text-webpack-plugin": "^1.0.1", 26 | "file-loader": "^0.8.4", 27 | "fill-range": "^2.2.2", 28 | "gulp": "^3.9.0", 29 | "gulp-changed": "^1.2.1", 30 | "gulp-inject": "^3.0.0", 31 | "gulp-inject-string": "^1.1.0", 32 | "gulp-load-plugins": "^1.2.0", 33 | "gulp-size": "^2.0.0", 34 | "gulp-util": "^3.0.5", 35 | "json-loader": "^0.5.3", 36 | "less": "^2.5.3", 37 | "less-loader": "^2.2.0", 38 | "node-libs-browser": "^1.0.0", 39 | "open": "0.0.5", 40 | "redux-devtools": "^3.1.1", 41 | "run-sequence": "^1.1.0", 42 | "style-loader": "^0.13.0", 43 | "url-loader": "^0.5.6", 44 | "webpack": "^1.9.10", 45 | "webpack-dev-server": "^1.9.0" 46 | }, 47 | "dependencies": { 48 | "babel-polyfill": "^6.1.4", 49 | "babel-runtime": "^6.0.14", 50 | "invariant": "^2.1.1", 51 | "object-pick": "^0.1.1", 52 | "react": "^0.14.0", 53 | "react-dom": "^0.14.0", 54 | "react-pacomo": "^0.5.1", 55 | "redux": "^3.0.2", 56 | "redux-batched-subscribe": "^0.1.4", 57 | "redux-multi": "0.1.12", 58 | "redux-thunk": "^1.0.0", 59 | "uniloc": "^0.2.0" 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/components/DocumentList.jsx: -------------------------------------------------------------------------------- 1 | import './DocumentList.less' 2 | 3 | import React, {PropTypes} from 'react' 4 | import * as actions from '../actions/documentListView' 5 | import { pacomoTransformer } from '../utils/pacomo' 6 | import Link from './Link' 7 | 8 | 9 | function mapValue(fn) { 10 | return e => fn(e.target.value) 11 | } 12 | 13 | const DocumentList = ({ 14 | id: activeId, 15 | query, 16 | documents, 17 | onChangeQuery, 18 | }) => 19 |
20 |
21 | 28 |
29 |
    30 | {documents.map(([id, data]) => 31 |
  • 38 | 43 | {data.title} 44 | 45 |
  • 46 | )} 47 |
  • 53 | 58 | Add Document 59 | 60 |
  • 61 |
62 |
63 | 64 | DocumentList.propTypes = { 65 | id: PropTypes.string, 66 | query: PropTypes.string, 67 | documents: PropTypes.array.isRequired, 68 | onChangeQuery: PropTypes.func.isRequired, 69 | } 70 | 71 | export default pacomoTransformer(DocumentList) 72 | -------------------------------------------------------------------------------- /src/components/DocumentForm.jsx: -------------------------------------------------------------------------------- 1 | import './DocumentForm.less' 2 | 3 | import React, {PropTypes} from 'react' 4 | import * as actions from '../actions/documentView' 5 | import { pacomoTransformer } from '../utils/pacomo' 6 | 7 | 8 | function updater(original, prop, fn) { 9 | return e => fn(Object.assign({}, original, {[prop]: e.target.value})) 10 | } 11 | 12 | function preventDefault(fn) { 13 | return e => { 14 | e.preventDefault() 15 | fn && fn(e) 16 | } 17 | } 18 | 19 | const errorMap = (error, i) =>
  • {error}
  • 20 | 21 | const DocumentForm = ({ 22 | data, 23 | errors, 24 | onUpdate, 25 | onSubmit, 26 | onCancel, 27 | }) => 28 |
    32 |
      33 | {errors && Object.values(errors).map(errorMap)} 34 |
    35 | 43 |