Adding the frontend project as files

This commit is contained in:
Andrew Revinsky
2016-02-08 23:17:37 +03:00
parent 51e9d6c7fa
commit 7f1f2af188
46 changed files with 1346 additions and 0 deletions

3
js-frontend/.babelrc Normal file
View File

@@ -0,0 +1,3 @@
{
"presets": ["es2015"]
}

4
js-frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
node_modules
build
dist
dist-intermediate

153
js-frontend/README.md Normal file
View File

@@ -0,0 +1,153 @@
# Unicorn Standard Starter Kit
This starter kit provides you with the code and conventions you need to get straight into building your React/Redux based app.
## Happiness is six lines away
*Prerequisites: node.js and git*
```
git clone https://github.com/unicorn-standard/starter-kit.git your-repo-name
cd your-repo-name
npm install
npm start
npm run open # (from a different console window, otherwise open localhost:3000)
```
Presto, you've got a ready-to-customise application!
![Unicorn Standard Starter Kit](http://unicornstandard.com/files/boilerplate.png?1)
## Why use Unicorn Standard Starter Kit?
- Your directory structure is sorted as soon as you `git clone`
- ES6 compilation and automatic-reloading development server are configured for you with [webpack](https://webpack.github.io/) and [Babel](https://babeljs.io/)
- [Redux](http://redux.js.org/) is an incredibly simple way of modelling your data, with great community support
- [React](https://www.reactjs.org/) is an incredibly simple way of rendering your views, and is maintained by Facebook
- Simple [uniloc](http://unicornstandard.com/packages/uniloc.html)-based routing is included - easy to understand, and easy to customize
- The [Pacomo](http://unicornstandard.com/packages/pacomo.html) CSS conventions eliminate bugs caused by conflicting styles
- The actors pattern allows you to easily react to changes on your store *without* forcing a re-render
- Your redux store is already configured with navigation, data and view models
- Comes with views, layouts and reducers for a simple document editor!
## Getting Started
#### Put your name on it
- Update name, desription and author in `package.json`
- Update app title in `src/index.html`
- Restart the dev server (make sure to do this after any changes to `src/index.html`)
#### Make sure your editor is happy
- Setup ES6 syntax highlighting on extensions `.js` and `.jsx` (see [babel-sublime](https://github.com/babel/babel-sublime))
#### Start building
- Add a route to `src/constants/ROUTES.js`
- Add a nav menu item for your route in `src/components/ApplicationLayout.jsx`
- Add a component for your route in `src/components`
- Add reducers and actions for your component's view model in `src/actions` and `src/reducers/view`
- Add any data models which your component reqiures in `src/reducers/data`
- Add a container to map your store's `state` and `dispatch` to component props in `src/containers`
- Configure your route in `src/Application.jsx`
- Bask in the glory of your creation
- Don't forget to commit your changes and push to Bitbucket or GitHub!
#### Show your friends
- Run `gulp dist` to output a web-ready build of your app to `dist`
## Structure
### Entry point
`main.js` is the entry point to your application. It defines your redux store, handles any actions dispatched to your redux store, handles changes to the browser's current URL, and also makes an initial route change dispatch.
Most of the above will be obvious from a quick read through `main.js` - if there is one thing which may strike you as "interesting", it'll be the block which handles actors.
### Actors
*[Read the introduction to actors](http://jamesknelson.com/join-the-dark-side-of-the-flux-responding-to-actions-with-actors/)*
Each time your store's state changes, a sequence of functions are called on the *current state* of your store. These functions are called **actors**.
There is one important exception to this rule: actors will not be called if `main.js` is currently in the midst of calling the sequence from a previous update. This allows earlier actors in a sequence to dispatch actions to the store, with later actors in the sequence receiving the *updated* state.
The code which accomplishes this is very small:
```javascript
let acting = false
store.subscribe(function() {
// Ensure that any action dispatched by actors do not result in a new
// actor run, allowing actors to dispatch with impunity.
if (!acting) {
acting = true
for (let actor of actors) {
actor(store.getState(), store.dispatch.bind(store))
}
acting = false
}
})
```
The actor is defined in `src/actors/index.js`. By default, it runs the following sequence:
- **redirector** - dispatch a navigation action if the current location should redirect to another location
- **renderer** - renders your <Application> component with React
### Model
Your model (i.e. reducers and actions) is pre-configured with three parts:
#### Navigation
The `navigation` state holds the following information:
- `location` is the object which your `ROUTES` constant's `lookup` function returns for the current URL. With the default uniloc-based `ROUTES` object, this will have a string `name` property, and an `options` object containing any route parameters.
- `transitioning` is true if a navigation `start` action has been dispatched, but the browser hasn't changed URL yet
#### Data
The `data` state can be thought of as the database for your application. If your application reads data from a remote server, it should be stored here. Any metadata should also be stored here, including the time it was fetched or its current version number.
#### View
The `view` state has a property for each of the view's in your app, holding their current state. For example, form state should be stored in the view models.
### Directories
- `src/actions` - Redux action creators
- `src/actors` - Handle changes to your store's state
- `src/components` - React components, stateless where possible
- `src/constants` - Define stateless data
- `src/containers` - Unstyled "smart" components which take your store's `state` and `dispatch`, and possibly navigation `location`, and pass them to "dumb" components
- `src/reducers` - Redux reducers
- `src/static` - Files which will be copied across to the root directory on build
- `src/styles` - Helpers for stylesheets for individual components
- `src/utils` - General code which isn't specific to your application
- `src/validators` - Functions which take an object containing user entry and return an object containing any errors
Other directories:
- `build` - Intermediate files produced by the development server. Don't touch these.
- `dist` - The output of `gulp dist`, which contains your distribution-ready app.
- `config/environments` - The build system will assign one of these to the `environment` module, depending on the current build environment.
Main application files:
- `src/Application.jsx` - Your application's top-level React component
- `src/index.html` - The single page for your single page application
- `src/main.js` - The application's entry point
- `src/main.less` - Global styles for your application
Main build files:
- `gulpfile.babel.js` - Build scripts written with [gulp](http://gulpjs.com/)
- `webpack.config.js` - [Webpack](http://webpack.github.io/) configuration
## TODO
- Watch `static` and `index.html` for changes and copy them across to `build` when appropriate

View File

@@ -0,0 +1,3 @@
export default {
identityProperty: 'APP_IDENTITY',
}

View File

@@ -0,0 +1,3 @@
export default {
identityProperty: 'APP_IDENTITY',
}

View File

@@ -0,0 +1,96 @@
import del from "del";
import path from "path";
import gulp from "gulp";
import open from "open";
import gulpLoadPlugins from "gulp-load-plugins";
import packageJson from "./package.json";
import runSequence from "run-sequence";
import webpack from "webpack";
import webpackConfig from "./webpack.config";
import WebpackDevServer from "webpack-dev-server";
const PORT = process.env.PORT || 3000;
const $ = gulpLoadPlugins({camelize: true});
// Main tasks
gulp.task('serve', () => runSequence('serve:clean', 'serve:index', 'serve:start'));
gulp.task('dist', () => runSequence('dist:clean', 'dist:build', 'dist:index'));
gulp.task('clean', ['dist:clean', 'serve:clean']);
gulp.task('open', () => open('http://localhost:3000'));
// Remove all built files
gulp.task('serve:clean', cb => del('build', {dot: true}, cb));
gulp.task('dist:clean', cb => del(['dist', 'dist-intermediate'], {dot: true}, cb));
// Copy static files across to our final directory
gulp.task('serve:static', () =>
gulp.src([
'src/static/**'
])
.pipe($.changed('build'))
.pipe(gulp.dest('build'))
.pipe($.size({title: 'static'}))
);
gulp.task('dist:static', () =>
gulp.src([
'src/static/**'
])
.pipe(gulp.dest('dist'))
.pipe($.size({title: 'static'}))
);
// Copy our index file and inject css/script imports for this build
gulp.task('serve:index', () => {
return gulp
.src('src/index.html')
.pipe($.injectString.after('<!-- inject:app:js -->', '<script src="generated/main.js"></script>'))
.pipe(gulp.dest('build'));
});
// Copy our index file and inject css/script imports for this build
gulp.task('dist:index', () => {
const app = gulp
.src(["*.{css,js}"], {cwd: 'dist-intermediate/generated'})
.pipe(gulp.dest('dist'));
// Build the index.html using the names of compiled files
return gulp.src('src/index.html')
.pipe($.inject(app, {
ignorePath: 'dist',
starttag: '<!-- inject:app:{{ext}} -->'
}))
.on("error", $.util.log)
.pipe(gulp.dest('dist'));
});
// Start a livereloading development server
gulp.task('serve:start', ['serve:static'], () => {
const config = webpackConfig(true, 'build', PORT);
return new WebpackDevServer(webpack(config), {
contentBase: 'build',
publicPath: config.output.publicPath,
watchDelay: 100
})
.listen(PORT, '0.0.0.0', (err) => {
if (err) throw new $.util.PluginError('webpack-dev-server', err);
$.util.log(`[${packageJson.name} serve]`, `Listening at 0.0.0.0:${PORT}`);
});
});
// Create a distributable package
gulp.task('dist:build', ['dist:static'], cb => {
const config = webpackConfig(false, 'dist-intermediate');
webpack(config, (err, stats) => {
if (err) throw new $.util.PluginError('dist', err);
$.util.log(`[${packageJson.name} dist]`, stats.toString({colors: true}));
cb();
});
});

61
js-frontend/package.json Normal file
View File

@@ -0,0 +1,61 @@
{
"name": "",
"description": "",
"author": "",
"private": true,
"version": "0.1.0",
"license": "MIT",
"main": "static",
"scripts": {
"start": "gulp serve",
"open": "gulp open",
"gulp": "gulp"
},
"devDependencies": {
"autoprefixer-loader": "^2.0.0",
"babel-core": "6.1.4",
"babel-loader": "6.1.0",
"babel-plugin-transform-runtime": "6.1.4",
"babel-preset-es2015": "6.1.4",
"babel-preset-react": "6.1.4",
"babel-preset-stage-0": "6.1.2",
"babel-register": "6.1.4",
"css-loader": "^0.14.4",
"del": "^1.2.0",
"extract-text-webpack-plugin": "^0.8.1",
"file-loader": "^0.8.4",
"fill-range": "^2.2.2",
"gulp": "^3.9.0",
"gulp-changed": "^1.2.1",
"gulp-inject": "^1.3.1",
"gulp-inject-string": "0.0.2",
"gulp-load-plugins": "^0.10.0",
"gulp-size": "^1.2.1",
"gulp-util": "^3.0.5",
"json-loader": "^0.5.3",
"less": "^2.5.3",
"less-loader": "^2.2.0",
"node-libs-browser": "^0.5.2",
"open": "0.0.5",
"redux-devtools": "^2.1.5",
"run-sequence": "^1.1.0",
"style-loader": "^0.12.3",
"url-loader": "^0.5.6",
"webpack": "^1.9.10",
"webpack-dev-server": "^1.9.0"
},
"dependencies": {
"babel-polyfill": "6.1.4",
"babel-runtime": "6.0.14",
"invariant": "^2.1.1",
"object-pick": "^0.1.1",
"react": "^0.14.0",
"react-dom": "^0.14.0",
"react-pacomo": "^0.5.1",
"redux": "^3.0.2",
"redux-batched-subscribe": "^0.1.4",
"redux-multi": "^0.1.9",
"redux-thunk": "^1.0.0",
"uniloc": "^0.2.0"
}
}

View File

@@ -0,0 +1,36 @@
import React, {PropTypes} from 'react'
import ApplicationLayout from './components/ApplicationLayout'
import DocumentContainer from './containers/DocumentContainer'
import DocumentListContainer from './containers/DocumentListContainer'
// Application is the root component for your application.
export default function Application(props) {
return (
<ApplicationLayout locationName={props.state.navigation.location.name}>
{selectChildContainer(props)}
</ApplicationLayout>
)
}
Application.propTypes = {
state: PropTypes.object.isRequired,
dispatch: PropTypes.func.isRequired,
}
// Define this as a separate function to allow us to use the switch statement
// with `return` statements instead of `break`
const selectChildContainer = props => {
const location = props.state.navigation.location
let child
switch (location.name) {
case 'documentEdit':
child = <DocumentContainer {...props} id={location.options.id} />
case 'documentList':
return <DocumentListContainer {...props} id={location.options.id}>{child}</DocumentListContainer>
default:
return "Not Found"
}
}

View File

@@ -0,0 +1,9 @@
import T from '../constants/ACTION_TYPES'
export function updateQuery(query) {
return {
type: T.DOCUMENT_LIST_VIEW.SET_QUERY,
query,
}
}

View File

@@ -0,0 +1,60 @@
import uuid from '../utils/uuid'
import documentValidator from '../validators/documentValidator'
import T from '../constants/ACTION_TYPES'
import * as navigation from './navigation'
export function updateChanges(id, data) {
return [
{
type: T.DOCUMENT_VIEW.UPDATE_DATA,
id,
data,
},
{
type: T.DOCUMENT_VIEW.REMOVE_STALE_ERRORS,
id,
errors: documentValidator(data),
},
]
}
export function clearChanges(id) {
return {
type: T.DOCUMENT_VIEW.CLEAR,
id,
}
}
export function cancelChanges(id) {
return [
clearChanges(id),
navigation.start('documentList'),
]
}
export function submitChanges(id) {
return (dispatch, getState) => {
const { view } = getState()
const data = view.document.unsavedChanges[id]
const errors = documentValidator(data)
if (errors) {
dispatch({
type: T.DOCUMENT_VIEW.SET_ERRORS,
id,
errors,
})
}
else {
const newId = id == 'new' ? uuid() : id
dispatch(navigation.start('documentEdit', {id: newId}))
dispatch(clearChanges(id))
dispatch({
type: T.DOCUMENT_DATA.UPDATE,
id: newId,
data,
})
}
}
}

View File

@@ -0,0 +1,29 @@
import T from '../constants/ACTION_TYPES'
import ROUTES from '../constants/ROUTES'
// `navigate` is used to facilitate changing routes within another action
// without rendering any other changes first
export function start(name, options) {
return dispatch => {
const currentURI = window.location.hash.substr(1)
const newURI = ROUTES.generate(name, options)
if (currentURI != newURI) {
dispatch({
type: T.NAVIGATION.START,
})
window.location.replace(
window.location.pathname + window.location.search + '#' + newURI
)
}
}
}
export function complete() {
return {
type: T.NAVIGATION.COMPLETE,
location: ROUTES.lookup(window.location.hash.substr(1)),
}
}

View File

@@ -0,0 +1,7 @@
import redirector from './redirector'
import renderer from './renderer'
export default [
redirector,
renderer,
]

View File

@@ -0,0 +1,20 @@
import * as navigation from '../actions/navigation'
import ROUTES from '../constants/ROUTES'
export default function redirector(state, dispatch) {
const {name, options} = state.navigation.location || {}
const currentURI = window.location.hash.substr(1)
const canonicalURI = name && ROUTES.generate(name, options)
if (canonicalURI && canonicalURI !== currentURI) {
// If the URL entered includes extra `/` characters, or otherwise
// differs from the canonical URL, navigate the user to the
// canonical URL (which will result in `complete` being called again)
dispatch(navigation.start(name, options))
}
else if (name == 'root') {
// If we've hit the root location, redirect the user to the main page
dispatch(navigation.start('documentList'))
}
}

View File

@@ -0,0 +1,18 @@
import React from 'react'
import ReactDOM from 'react-dom'
import Application from '../Application'
// Store a reference to our application's root DOM node to prevent repeating
// this on every state update
const APP_NODE = document.getElementById('react-app')
export default function renderer(state, dispatch) {
// Don't re-render if we're in the process of navigating to a new page
if (!state.navigation.transitioning) {
ReactDOM.render(
<Application state={state} dispatch={dispatch} />,
APP_NODE
)
}
}

View File

@@ -0,0 +1,34 @@
import './ApplicationLayout.less'
import React, {PropTypes} from 'react'
import { pacomoTransformer } from '../utils/pacomo'
import Link from './Link'
const ApplicationLayout = ({
children,
locationName,
}) =>
<div>
<nav className='navbar'>
<Link
name='documentList'
className={{
'link': true,
'link-active': locationName == 'documentList' || locationName == 'documentEdit',
}}
>
Documents
</Link>
</nav>
<main className='content'>
{children}
</main>
</div>
ApplicationLayout.propTypes = {
children: PropTypes.element.isRequired,
locationName: PropTypes.string,
}
export default pacomoTransformer(ApplicationLayout)

View File

@@ -0,0 +1,26 @@
.app-ApplicationLayout {
position: relative;
height: 100%;
&-navbar {
position: fixed;
left: 0;
top: 0;
bottom: 0;
width: 192px;
border-right: 1px solid black;
z-index: 2;
}
&-content {
position: relative;
padding-left: 192px;
width: 100%;
height: 100%;
}
&-link-active {
font-weight: bold;
}
}

View File

@@ -0,0 +1,75 @@
import './DocumentForm.less'
import React, {PropTypes} from 'react'
import * as actions from '../actions/documentView'
import { pacomoTransformer } from '../utils/pacomo'
function updater(original, prop, fn) {
return e => fn(Object.assign({}, original, {[prop]: e.target.value}))
}
function preventDefault(fn) {
return e => {
e.preventDefault()
fn && fn(e)
}
}
const errorMap = (error, i) => <li className='error' key={i}>{error}</li>
const DocumentForm = ({
data,
errors,
onUpdate,
onSubmit,
onCancel,
}) =>
<form
onSubmit={preventDefault(onSubmit)}
noValidate={true}
>
<ul className='errors'>
{errors && Object.values(errors).map(errorMap)}
</ul>
<input
type='text'
className='title'
placeholder='Title'
onChange={updater(data, 'title', onUpdate)}
value={data.title || ''}
autoFocus
/>
<textarea
type='text'
className='content'
onChange={updater(data, 'content', onUpdate)}
value={data.content || ''}
/>
<footer className='buttons'>
<button
type='button'
className='cancel'
onClick={onCancel}
>
Cancel
</button>
<button
type='submit'
className='submit'
disabled={!onSubmit}
>
Save
</button>
</footer>
</form>
DocumentForm.propTypes = {
data: PropTypes.object.isRequired,
errors: PropTypes.object,
onUpdate: PropTypes.func.isRequired,
onSubmit: PropTypes.func,
onCancel: PropTypes.func.isRequired,
}
export default pacomoTransformer(DocumentForm)

View File

@@ -0,0 +1,25 @@
.app-DocumentForm {
width: 100%;
padding: 5px;
&-errors {
list-style: none;
padding: 0;
margin: 0;
}
&-error {
color: red;
margin-bottom: 5px;
}
&-title,
&-content {
display: block;
width: 100%;
margin-bottom: 5px;
}
&-cancel {
margin-right: 5px;
}
}

View File

@@ -0,0 +1,71 @@
import './DocumentList.less'
import React, {PropTypes} from 'react'
import * as actions from '../actions/documentListView'
import { pacomoTransformer } from '../utils/pacomo'
import Link from './Link'
function mapValue(fn) {
return e => fn(e.target.value)
}
const DocumentList = ({
id: activeId,
query,
documents,
onChangeQuery,
}) =>
<div>
<header className='header'>
<input
className='query'
type="text"
placeholder="Search..."
value={query}
onChange={mapValue(onChangeQuery)}
/>
</header>
<ul className='list'>
{documents.map(([id, data]) =>
<li
key={id}
className={{
'document-item': true,
'document-item-active': activeId == id,
}}
>
<Link
className='document-link'
name='documentEdit'
options={{id}}
>
{data.title}
</Link>
</li>
)}
<li
className={{
'add-item': true,
'add-item-active': activeId == 'new',
}}
>
<Link
className='add-link'
name='documentEdit'
options={{id: 'new'}}
>
Add Document
</Link>
</li>
</ul>
</div>
DocumentList.propTypes = {
id: PropTypes.string,
query: PropTypes.string,
documents: PropTypes.array.isRequired,
onChangeQuery: PropTypes.func.isRequired,
}
export default pacomoTransformer(DocumentList)

View File

@@ -0,0 +1,33 @@
.app-DocumentList {
width: 100%;
&-header {
width: 100%;
padding: 5px;
}
&-query {
width: 100%;
}
&-list {
list-style: none;
margin: 0;
padding: 0;
}
&-document-item-active,
&-add-item-active {
background-color: #f0f0f0;
}
&-document-link,
&-add-link {
display: block;
padding: 5px;
&:hover {
background-color: #f8f8f8;
}
}
}

View File

@@ -0,0 +1,19 @@
import React, {PropTypes} from 'react'
import ROUTES from '../constants/ROUTES'
const Link = ({
name,
options,
children,
...props
}) =>
<a {...props} href={'#'+ROUTES.generate(name, options)}>{children}</a>
Link.propTypes = {
name: PropTypes.string.isRequired,
options: PropTypes.object,
children: PropTypes.node.isRequired,
}
export default Link

View File

@@ -0,0 +1,25 @@
import './OneOrTwoColumnLayout.less'
import React, {PropTypes} from 'react'
import { pacomoTransformer } from '../utils/pacomo'
const OneOrTwoColumnLayout = ({
left,
right,
}) =>
<div>
<div className={{'left': true, 'left-open': left}}>
{left}
</div>
<div className={{'right': true, 'right-open': right}}>
{right}
</div>
</div>
OneOrTwoColumnLayout.propTypes = {
left: PropTypes.element,
right: PropTypes.element,
}
export default pacomoTransformer(OneOrTwoColumnLayout)

View File

@@ -0,0 +1,24 @@
.app-OneOrTwoColumnLayout {
position: relative;
height: 100%;
z-index: 1;
&-left {
position: absolute;
left: 0;
width: 50%;
height: 100%;
overflow: hidden;
}
&-right {
position: absolute;
right: 0;
width: 50%;
height: 100%;
overflow: hidden;
}
&-right-open {
border-left: 1px solid black;
}
}

View File

@@ -0,0 +1,36 @@
import defineActionTypes from '../utils/defineActionTypes'
export default defineActionTypes({
/*
* View model
*/
DOCUMENT_LIST_VIEW: `
SET_QUERY
`,
DOCUMENT_VIEW: `
UPDATE_DATA
SET_ERRORS
REMOVE_STALE_ERRORS
CLEAR
`,
/*
* Data model
*/
DOCUMENT_DATA: `
UPDATE
`,
/*
* Application
*/
NAVIGATION: `
START
COMPLETE
`,
})

View File

@@ -0,0 +1,7 @@
import uniloc from 'uniloc'
export default uniloc({
root: 'GET /',
documentList: 'GET /documents',
documentEdit: 'GET /documents/:id',
})

View File

@@ -0,0 +1,27 @@
import React, {PropTypes} from 'react'
import * as actions from '../actions/documentView'
import compose from '../utils/compose'
import partial from '../utils/partial'
import DocumentForm from '../components/DocumentForm'
export default function DocumentContainer({state, dispatch, id}) {
const errors = state.view.document.saveErrors[id]
const viewData = state.view.document.unsavedChanges[id]
const data =
viewData ||
state.data.document[id] ||
(id == 'new' && {})
const props = {
data,
errors,
onUpdate: compose(dispatch, partial(actions.updateChanges, id)),
onCancel: compose(dispatch, partial(actions.cancelChanges, id)),
onSubmit:
viewData && !errors
? compose(dispatch, partial(actions.submitChanges, id))
: null,
}
return !data ? <div>Not Found</div> : <DocumentForm {...props} />
}

View File

@@ -0,0 +1,32 @@
import React, {PropTypes} from 'react'
import * as actions from '../actions/documentListView'
import compose from '../utils/compose'
import OneOrTwoColumnLayout from '../components/OneOrTwoColumnLayout'
import DocumentList from '../components/DocumentList'
function listPredicate(query) {
return (
!query
? () => true
: ([id, data]) => data.title.replace(/\s+/g, '').indexOf(query) !== -1
)
}
export default function DocumentListContainer({state, dispatch, children, id}) {
const query = state.view.documentList
const props = {
id,
query,
documents: Object
.entries(state.data.document)
.filter(listPredicate(query)),
onChangeQuery: compose(dispatch, actions.updateQuery),
}
return <OneOrTwoColumnLayout
left={<DocumentList {...props} />}
right={children}
/>
}

View File

@@ -0,0 +1,18 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>Unicorn Standard Boilerplate</title>
<!-- inject:app:css -->
<!-- endinject -->
</head>
<body>
<div id="react-app"></div>
<!-- inject:app:js -->
<!-- endinject -->
</body>
</html>

47
js-frontend/src/main.js Normal file
View File

@@ -0,0 +1,47 @@
import { createStore, applyMiddleware } from 'redux'
import reduxThunk from 'redux-thunk'
import reduxMulti from 'redux-multi'
import { batchedSubscribe } from 'redux-batched-subscribe'
import * as navigation from './actions/navigation'
import actors from './actors'
import rootReducer from './reducers'
// Add middleware to allow our action creators to return functions and arrays
const createStoreWithMiddleware = applyMiddleware(
reduxThunk,
reduxMulti,
)(createStore)
// Ensure our listeners are only called once, even when one of the above
// middleware call the underlying store's `dispatch` multiple times
const createStoreWithBatching = batchedSubscribe(
fn => fn()
)(createStoreWithMiddleware)
// Create a store with our application reducer
const store = createStoreWithBatching(rootReducer)
// Handle changes to our store with a list of actor functions, but ensure
// that the actor sequence cannot be started by a dispatch from an actor
let acting = false
store.subscribe(function() {
if (!acting) {
acting = true
for (let actor of actors) {
actor(store.getState(), store.dispatch)
}
acting = false
}
})
// Dispatch navigation events when the URL's hash changes, and when the
// application loads
function onHashChange() {
store.dispatch(navigation.complete())
}
window.addEventListener('hashchange', onHashChange, false)
onHashChange()

46
js-frontend/src/main.less Normal file
View File

@@ -0,0 +1,46 @@
/*
* This file contains Global styles.
*
* In general, your styles should *not* be in this file, but in the individual
* component files. For details, see the Pacomo specification:
*
* https://github.com/unicorn-standard/pacomo
*/
@import url('http://fonts.googleapis.com/css?family=Roboto:300,400,500');
* {
box-sizing: border-box;
margin: 0;
}
*:before,
*:after {
box-sizing: border-box;
}
html, body, main {
position: relative;
height: 100%;
min-height: 100%;
font-family: Roboto;
}
body {
-webkit-tap-highlight-color: rgba(0,0,0,0);
}
// Reset fonts for relevant elements
input,
button,
select,
textarea {
font-family: inherit;
font-size: inherit;
line-height: inherit;
}
#react-app {
position: relative;
height: 100%;
min-height: 100%;
}

View File

@@ -0,0 +1,13 @@
import typeReducers from '../../utils/typeReducers'
import ACTION_TYPES from '../../constants/ACTION_TYPES'
const defaultState = {}
export default typeReducers(ACTION_TYPES.DOCUMENT_DATA, defaultState, {
UPDATE: (state, {id, data}) => ({
...state,
[id]: { ...state[id], ...data },
})
})

View File

@@ -0,0 +1,21 @@
import { combineReducers } from 'redux'
import navigation from './navigationReducer'
import documentListView from './view/documentListViewReducer'
import documentView from './view/documentViewReducer'
import documentData from './data/documentDataReducer'
export default combineReducers({
navigation,
view: combineReducers({
documentList: documentListView,
document: documentView,
}),
data: combineReducers({
document: documentData,
}),
})

View File

@@ -0,0 +1,19 @@
import typeReducers from '../utils/typeReducers'
import ACTION_TYPES from '../constants/ACTION_TYPES'
const defaultState = {
transitioning: true,
location: null,
}
export default typeReducers(ACTION_TYPES.NAVIGATION, defaultState, {
START: () => ({
transitioning: true,
}),
COMPLETE: (state, {location}) => ({
transitioning: false,
location,
}),
})

View File

@@ -0,0 +1,10 @@
import typeReducers from '../../utils/typeReducers'
import ACTION_TYPES from '../../constants/ACTION_TYPES'
const defaultState = ''
export default typeReducers(ACTION_TYPES.DOCUMENT_LIST_VIEW, defaultState, {
SET_QUERY: (state, {query}) => query,
})

View File

@@ -0,0 +1,53 @@
import pick from 'object-pick'
import typeReducers from '../../utils/typeReducers'
import compact from '../../utils/compact'
import ACTION_TYPES from '../../constants/ACTION_TYPES'
const defaultState = {
unsavedChanges: {},
saveErrors: {},
}
export default typeReducers(ACTION_TYPES.DOCUMENT_VIEW, defaultState, {
// Update the current document data
UPDATE_DATA: (state, {id, data}) => ({
...state,
unsavedChanges: {
...state.unsavedChanges,
[id]: { ...state.unsavedChanges[id], ...data },
},
}),
// If there are fields marked as invalid which are now valid,
// mark them as valid
REMOVE_STALE_ERRORS: (state, {id, errors}) => ({
...state,
saveErrors: {
...state.saveErrors,
[id]: compact(pick(state.saveErrors[id], Object.keys(errors || {}))),
},
}),
// Set the errors to the passed in object
SET_ERRORS: (state, {id, errors}) => ({
...state,
saveErrors: {
...state.saveErrors,
[id]: errors
},
}),
// Remove errors/data for an id
CLEAR: (state, {id}) => ({
unsavedChanges: {
...state.unsavedChanges,
[id]: null,
},
saveErrors: {
...state.saveErrors,
[id]: null,
},
}),
})

View File

View File

View File

@@ -0,0 +1,12 @@
export default function compact(obj) {
let entries = Object.entries(obj)
let result = Object.assign({}, obj)
let count = entries.length
for (let [key, value] of entries) {
if (!value) {
count -= 1
delete result[key]
}
}
return count === 0 ? null : result
}

View File

@@ -0,0 +1,4 @@
export default function compose(...funcs) {
const innerFunc = funcs.pop()
return (...args) => funcs.reduceRight((composed, f) => f(composed), innerFunc(...args))
}

View File

@@ -0,0 +1,33 @@
import invariant from 'invariant'
export default function defineActionTypes(obj) {
const result = {}
for (let [namespace, value] of Object.entries(obj)) {
let types = value.trim().split(/\s+/)
const namespaceTypes = {}
invariant(
/^[A-Z][A-Z0-9_]*$/.test(namespace),
"Namespace names must start with a capital letter, and be composed entirely of capital letters, numbers, and the underscore character."
)
invariant(
(new Set(types)).size == types.length,
"There must be no repeated action types passed to defineActionTypes"
)
for (let type of types) {
invariant(
/^[A-Z][A-Z0-9_]*$/.test(type),
"Types must start with a capital letter, and be composed entirely of capital letters, numbers, and the underscore character."
)
namespaceTypes[type] = `@@app/${namespace}/${type}`
}
result[namespace] = namespaceTypes
}
return result
}

View File

@@ -0,0 +1,10 @@
import { withPackageName } from 'react-pacomo'
const {
decorator: pacomoDecorator,
transformer: pacomoTransformer,
} = withPackageName('app')
export {pacomoTransformer, pacomoDecorator}

View File

@@ -0,0 +1,3 @@
export default function partial(fn, ...firstArgs) {
return (...args) => fn(...firstArgs, ...args)
}

View File

@@ -0,0 +1,9 @@
export default function typeReducers(actionTypes, defaultState, reducers) {
const inverseActionTypes =
new Map(Object.entries(actionTypes).map(([x, y]) => [y, x]))
return (state = defaultState, action) => {
const reducer = reducers[inverseActionTypes.get(action.type)]
return reducer ? reducer(state, action) : state
}
}

View File

@@ -0,0 +1,10 @@
function uuidReplacer(c) {
const r = Math.random()*16|0
const v = c == 'x' ? r : (r&0x3|0x8)
return v.toString(16)
}
export default function uuid() {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, uuidReplacer)
}

View File

@@ -0,0 +1,10 @@
import compact from '../utils/compact'
export default function documentValidator(data) {
return compact({
titleExists:
!data.title &&
"You must specify a title for your document",
})
}

View File

@@ -0,0 +1,92 @@
import path from "path";
import webpack from "webpack";
import ExtractTextPlugin from "extract-text-webpack-plugin";
export default (DEBUG, PATH, PORT=3000) => ({
entry: (DEBUG ? [
`webpack-dev-server/client?http://localhost:${PORT}`,
] : []).concat([
'./src/main.less',
'babel-polyfill',
'./src/main',
]),
output: {
path: path.resolve(__dirname, PATH, "generated"),
filename: DEBUG ? "main.js" : "main-[hash].js",
publicPath: "/generated/"
},
cache: DEBUG,
debug: DEBUG,
// For options, see http://webpack.github.io/docs/configuration.html#devtool
devtool: DEBUG && "eval",
module: {
loaders: [
// Load ES6/JSX
{ test: /\.jsx?$/,
include: [
path.resolve(__dirname, "src"),
],
loader: "babel-loader",
query: {
plugins: ['transform-runtime'],
presets: ['es2015', 'stage-0', 'react'],
}
},
// Load styles
{ test: /\.less$/,
loader: DEBUG
? "style!css!autoprefixer!less"
: ExtractTextPlugin.extract("style-loader", "css-loader!autoprefixer-loader!less-loader")
},
// Load images
{ test: /\.jpg/, loader: "url-loader?limit=10000&mimetype=image/jpg" },
{ test: /\.gif/, loader: "url-loader?limit=10000&mimetype=image/gif" },
{ test: /\.png/, loader: "url-loader?limit=10000&mimetype=image/png" },
{ test: /\.svg/, loader: "url-loader?limit=10000&mimetype=image/svg" },
// Load fonts
{ test: /\.woff(\?v=[0-9]\.[0-9]\.[0-9])?$/, loader: "url-loader?limit=10000&mimetype=application/font-woff" },
{ test: /\.(ttf|eot|svg)(\?v=[0-9]\.[0-9]\.[0-9])?$/, loader: "file-loader" },
]
},
plugins: DEBUG
? []
: [
new webpack.DefinePlugin({'process.env.NODE_ENV': '"production"'}),
new ExtractTextPlugin("style.css", {allChunks: false}),
new webpack.optimize.DedupePlugin(),
new webpack.optimize.UglifyJsPlugin({
compressor: {screw_ie8: true, keep_fnames: true, warnings: false},
mangle: {screw_ie8: true, keep_fnames: true}
}),
new webpack.optimize.OccurenceOrderPlugin(),
new webpack.optimize.AggressiveMergingPlugin(),
],
resolveLoader: {
root: path.join(__dirname, "node_modules"),
},
resolve: {
root: path.join(__dirname, "node_modules"),
modulesDirectories: ['node_modules'],
alias: {
environment: DEBUG
? path.resolve(__dirname, 'config', 'environments', 'development.js')
: path.resolve(__dirname, 'config', 'environments', 'production.js')
},
// Allow to omit extensions when requiring these files
extensions: ["", ".js", ".jsx"],
}
});