Adding the frontend project as files
This commit is contained in:
3
js-frontend/.babelrc
Normal file
3
js-frontend/.babelrc
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"presets": ["es2015"]
|
||||
}
|
||||
4
js-frontend/.gitignore
vendored
Normal file
4
js-frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
node_modules
|
||||
build
|
||||
dist
|
||||
dist-intermediate
|
||||
153
js-frontend/README.md
Normal file
153
js-frontend/README.md
Normal 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!
|
||||
|
||||

|
||||
|
||||
## 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
|
||||
3
js-frontend/config/environments/development.js
Normal file
3
js-frontend/config/environments/development.js
Normal file
@@ -0,0 +1,3 @@
|
||||
export default {
|
||||
identityProperty: 'APP_IDENTITY',
|
||||
}
|
||||
3
js-frontend/config/environments/production.js
Normal file
3
js-frontend/config/environments/production.js
Normal file
@@ -0,0 +1,3 @@
|
||||
export default {
|
||||
identityProperty: 'APP_IDENTITY',
|
||||
}
|
||||
96
js-frontend/gulpfile.babel.js
Normal file
96
js-frontend/gulpfile.babel.js
Normal 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
61
js-frontend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
36
js-frontend/src/Application.jsx
Normal file
36
js-frontend/src/Application.jsx
Normal 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"
|
||||
}
|
||||
}
|
||||
9
js-frontend/src/actions/documentListView.js
Normal file
9
js-frontend/src/actions/documentListView.js
Normal file
@@ -0,0 +1,9 @@
|
||||
import T from '../constants/ACTION_TYPES'
|
||||
|
||||
|
||||
export function updateQuery(query) {
|
||||
return {
|
||||
type: T.DOCUMENT_LIST_VIEW.SET_QUERY,
|
||||
query,
|
||||
}
|
||||
}
|
||||
60
js-frontend/src/actions/documentView.js
Normal file
60
js-frontend/src/actions/documentView.js
Normal 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,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
29
js-frontend/src/actions/navigation.js
Normal file
29
js-frontend/src/actions/navigation.js
Normal 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)),
|
||||
}
|
||||
}
|
||||
7
js-frontend/src/actors/index.js
Normal file
7
js-frontend/src/actors/index.js
Normal file
@@ -0,0 +1,7 @@
|
||||
import redirector from './redirector'
|
||||
import renderer from './renderer'
|
||||
|
||||
export default [
|
||||
redirector,
|
||||
renderer,
|
||||
]
|
||||
20
js-frontend/src/actors/redirector.js
Normal file
20
js-frontend/src/actors/redirector.js
Normal 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'))
|
||||
}
|
||||
}
|
||||
18
js-frontend/src/actors/renderer.js
Normal file
18
js-frontend/src/actors/renderer.js
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
34
js-frontend/src/components/ApplicationLayout.jsx
Normal file
34
js-frontend/src/components/ApplicationLayout.jsx
Normal 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)
|
||||
26
js-frontend/src/components/ApplicationLayout.less
Normal file
26
js-frontend/src/components/ApplicationLayout.less
Normal 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;
|
||||
}
|
||||
}
|
||||
75
js-frontend/src/components/DocumentForm.jsx
Normal file
75
js-frontend/src/components/DocumentForm.jsx
Normal 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)
|
||||
25
js-frontend/src/components/DocumentForm.less
Normal file
25
js-frontend/src/components/DocumentForm.less
Normal 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;
|
||||
}
|
||||
}
|
||||
71
js-frontend/src/components/DocumentList.jsx
Normal file
71
js-frontend/src/components/DocumentList.jsx
Normal 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)
|
||||
33
js-frontend/src/components/DocumentList.less
Normal file
33
js-frontend/src/components/DocumentList.less
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
19
js-frontend/src/components/Link.jsx
Normal file
19
js-frontend/src/components/Link.jsx
Normal 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
|
||||
25
js-frontend/src/components/OneOrTwoColumnLayout.jsx
Normal file
25
js-frontend/src/components/OneOrTwoColumnLayout.jsx
Normal 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)
|
||||
24
js-frontend/src/components/OneOrTwoColumnLayout.less
Normal file
24
js-frontend/src/components/OneOrTwoColumnLayout.less
Normal 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;
|
||||
}
|
||||
}
|
||||
36
js-frontend/src/constants/ACTION_TYPES.js
Normal file
36
js-frontend/src/constants/ACTION_TYPES.js
Normal 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
|
||||
`,
|
||||
})
|
||||
7
js-frontend/src/constants/ROUTES.js
Normal file
7
js-frontend/src/constants/ROUTES.js
Normal file
@@ -0,0 +1,7 @@
|
||||
import uniloc from 'uniloc'
|
||||
|
||||
export default uniloc({
|
||||
root: 'GET /',
|
||||
documentList: 'GET /documents',
|
||||
documentEdit: 'GET /documents/:id',
|
||||
})
|
||||
27
js-frontend/src/containers/DocumentContainer.jsx
Normal file
27
js-frontend/src/containers/DocumentContainer.jsx
Normal 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} />
|
||||
}
|
||||
32
js-frontend/src/containers/DocumentListContainer.jsx
Normal file
32
js-frontend/src/containers/DocumentListContainer.jsx
Normal 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}
|
||||
/>
|
||||
}
|
||||
18
js-frontend/src/index.html
Normal file
18
js-frontend/src/index.html
Normal 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
47
js-frontend/src/main.js
Normal 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
46
js-frontend/src/main.less
Normal 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%;
|
||||
}
|
||||
13
js-frontend/src/reducers/data/documentDataReducer.js
Normal file
13
js-frontend/src/reducers/data/documentDataReducer.js
Normal 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 },
|
||||
})
|
||||
})
|
||||
21
js-frontend/src/reducers/index.js
Normal file
21
js-frontend/src/reducers/index.js
Normal 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,
|
||||
}),
|
||||
})
|
||||
19
js-frontend/src/reducers/navigationReducer.js
Normal file
19
js-frontend/src/reducers/navigationReducer.js
Normal 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,
|
||||
}),
|
||||
})
|
||||
10
js-frontend/src/reducers/view/documentListViewReducer.js
Normal file
10
js-frontend/src/reducers/view/documentListViewReducer.js
Normal 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,
|
||||
})
|
||||
53
js-frontend/src/reducers/view/documentViewReducer.js
Normal file
53
js-frontend/src/reducers/view/documentViewReducer.js
Normal 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,
|
||||
},
|
||||
}),
|
||||
})
|
||||
0
js-frontend/src/static/.gitkeep
Normal file
0
js-frontend/src/static/.gitkeep
Normal file
0
js-frontend/src/theme/.gitkeep
Normal file
0
js-frontend/src/theme/.gitkeep
Normal file
12
js-frontend/src/utils/compact.js
Normal file
12
js-frontend/src/utils/compact.js
Normal 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
|
||||
}
|
||||
4
js-frontend/src/utils/compose.js
Normal file
4
js-frontend/src/utils/compose.js
Normal file
@@ -0,0 +1,4 @@
|
||||
export default function compose(...funcs) {
|
||||
const innerFunc = funcs.pop()
|
||||
return (...args) => funcs.reduceRight((composed, f) => f(composed), innerFunc(...args))
|
||||
}
|
||||
33
js-frontend/src/utils/defineActionTypes.js
Normal file
33
js-frontend/src/utils/defineActionTypes.js
Normal 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
|
||||
}
|
||||
10
js-frontend/src/utils/pacomo.js
Normal file
10
js-frontend/src/utils/pacomo.js
Normal file
@@ -0,0 +1,10 @@
|
||||
import { withPackageName } from 'react-pacomo'
|
||||
|
||||
|
||||
const {
|
||||
decorator: pacomoDecorator,
|
||||
transformer: pacomoTransformer,
|
||||
} = withPackageName('app')
|
||||
|
||||
|
||||
export {pacomoTransformer, pacomoDecorator}
|
||||
3
js-frontend/src/utils/partial.js
Normal file
3
js-frontend/src/utils/partial.js
Normal file
@@ -0,0 +1,3 @@
|
||||
export default function partial(fn, ...firstArgs) {
|
||||
return (...args) => fn(...firstArgs, ...args)
|
||||
}
|
||||
9
js-frontend/src/utils/typeReducers.js
Normal file
9
js-frontend/src/utils/typeReducers.js
Normal 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
|
||||
}
|
||||
}
|
||||
10
js-frontend/src/utils/uuid.js
Normal file
10
js-frontend/src/utils/uuid.js
Normal 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)
|
||||
}
|
||||
10
js-frontend/src/validators/documentValidator.js
Normal file
10
js-frontend/src/validators/documentValidator.js
Normal 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",
|
||||
})
|
||||
}
|
||||
92
js-frontend/webpack.config.js
Normal file
92
js-frontend/webpack.config.js
Normal 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"],
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user