commit c60d8ba706ca62a6904863ab3a1212c8e96df142 Author: Mathias Biilmann Christensen Date: Thu Feb 25 00:45:56 2016 -0800 Initial commit diff --git a/.babelrc b/.babelrc new file mode 100644 index 00000000..1f594c7a --- /dev/null +++ b/.babelrc @@ -0,0 +1,4 @@ +{ + "presets": ["react", "es2015"], + "plugins": ["transform-class-properties", "transform-object-assign", "transform-object-rest-spread"] +} diff --git a/.eslintrc b/.eslintrc new file mode 100644 index 00000000..fb3fe23e --- /dev/null +++ b/.eslintrc @@ -0,0 +1,125 @@ +env: + browser: true + +parser: babel-eslint + +plugins: [ "react" ] + +# enable ECMAScript features +ecmaFeatures: + arrowFunctions: true + binaryLiterals: true + blockBindings: true + classes: true + defaultParams: true + destructuring: true + forOf: true + generators: true + jsx: true + modules: true + objectLiteralShorthandMethods: true + objectLiteralShorthandProperties: true + octalLiterals: true + spread: true + templateStrings: true + +rules: + # Possible Errors + # https://github.com/eslint/eslint/tree/master/docs/rules#possible-errors + no-control-regex: 2 + no-debugger: 2 + no-dupe-args: 2 + no-dupe-keys: 2 + no-duplicate-case: 2 + no-empty-character-class: 2 + no-ex-assign: 2 + no-extra-boolean-cast : 2 + no-extra-semi: 2 + no-invalid-regexp: 2 + no-irregular-whitespace: 2 + no-proto: 2 + no-unexpected-multiline: 2 + no-unreachable: 2 + valid-typeof: 2 + + # Best Practices + # https://github.com/eslint/eslint/tree/master/docs/rules#best-practices + no-fallthrough: 2 + no-redeclare: 2 + + # Stylistic Issues + # https://github.com/eslint/eslint/tree/master/docs/rules#stylistic-issues + comma-spacing: 2 + eol-last: 2 + indent: [2, 2, {SwitchCase: 1}] + max-len: [2, 160, 2] + new-parens: 2 + no-mixed-spaces-and-tabs: 2 + no-multiple-empty-lines: [2, {max: 2}] + no-trailing-spaces: 2 + quotes: [2, "single", "avoid-escape"] + semi: 2 + space-after-keywords: 2 + space-before-blocks: [2, "always"] + space-before-function-paren: [2, "never"] + space-in-parens: [2, "never"] + space-infix-ops: 2 + space-return-throw-case: 2 + space-unary-ops: 2 + + # ECMAScript 6 + # http://eslint.org/docs/rules/#ecmascript-6 + arrow-parens: [2, "always"] + arrow-spacing: [2, {"before": true, "after": true}] + no-arrow-condition: 2 + prefer-const: 2 + + # Strict Mode + # https://github.com/eslint/eslint/tree/master/docs/rules#strict-mode + strict: [2, "global"] + + # Variables + # https://github.com/eslint/eslint/tree/master/docs/rules#variables + no-undef: 2 + no-unused-vars: [2, {"args": "none"}] + + + react/forbid-prop-types: 1 + react/jsx-boolean-value: 1 + react/jsx-closing-bracket-location: 1 + react/jsx-curly-spacing: 1 + react/jsx-equals-spacing: 1 + react/jsx-handler-names: 1 + react/jsx-indent-props: 1 + react/jsx-indent: [2, 2] + react/jsx-no-bind: 1 + react/jsx-no-duplicate-props: 1 + react/jsx-no-undef: 1 + react/jsx-pascal-case: 1 + react/jsx-sort-prop-types: 1 + react/jsx-uses-react: 1 + react/jsx-uses-vars: 1 + react/no-danger: 1 + react/no-deprecated: 1 + react/no-did-mount-set-state: 1 + react/no-did-update-set-state: 1 + react/no-direct-mutation-state: 1 + react/no-is-mounted: 1 + react/no-multi-comp: 1 + react/no-string-refs: 1 + react/no-unknown-property: 1 + react/prefer-es6-class: 1 + react/react-in-jsx-scope: 1 + react/require-extension: 1 + react/self-closing-comp: 1 + react/sort-comp: 1 + +# Global scoped method and vars +globals: + netlify: true + describe: true + it: true + require: true + process: true + module: true + CMS_ENV: true diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..a6ae2b5f --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +dist/ +node_modules/ +npm-debug.log diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 00000000..b8626c4c --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +4 diff --git a/example/config.yml b/example/config.yml new file mode 100644 index 00000000..1e549f22 --- /dev/null +++ b/example/config.yml @@ -0,0 +1,56 @@ +backend: + name: test-repo + delay: 0.1 + +media_folder: "assets/uploads" + +collections: # A list of collections the CMS should be able to edit + - name: "posts" # Used in routes, ie.: /admin/collections/:slug/edit + label: "Post" # Used in the UI, ie.: "New Post" + folder: "_posts" + slug: "{{year}}-{{month}}-{{day}}-{{slug}}" + create: true # Allow users to create new documents in this collection + fields: # The fields each document in this collection have + - {label: "Title", name: "title", widget: "string", tagname: "h1"} + - {label: "Cover Image", name: "image", widget: "image", optional: true, tagname: ""} + - {label: "Body", name: "body", widget: "markdown"} + meta: + - {label: "Publish Date", name: "date", widget: "datetime", format: "YYYY-MM-DD hh:mma"} + - {label: "SEO Description", name: "description", widget: "text"} + + - name: "faq" # Used in routes, ie.: /admin/collections/:slug/edit + label: "FAQ" # Used in the UI, ie.: "New Post" + folder: "_faqs" + create: true # Allow users to create new documents in this collection + fields: # The fields each document in this collection have + - {label: "Question", name: "title", widget: "string", tagname: "h1"} + - {label: "Answer", name: "body", widget: "markdown"} + + - name: "settings" + label: "Settings" + files: + - name: "general" + label: "Site Settings" + file: "_data/settings.json" + description: "General Site Settings" + fields: + - {label: "Global title", name: site_title, widget: "string"} + - label: "Post Settings" + name: posts + widget: "object" + fields: + - {label: "Number of posts on frontpage", name: front_limit, widget: number} + - {label: "Default Author", name: author, widget: string} + - {label: "Default Thumbnail", name: thumb, widget: image, class: "thumb"} + + - name: "authors" + label: "Authors" + file: "_data/authors.yml" + description: "Author descriptions" + fields: + - name: authors + label: Authors + widget: list + fields: + - {label: "Name", name: "name", widget: "string"} + - {label: "Description", name: "description", widget: "markdown"} diff --git a/example/index.html b/example/index.html new file mode 100644 index 00000000..d343c456 --- /dev/null +++ b/example/index.html @@ -0,0 +1,10 @@ + + + + This is an example + + + + + + diff --git a/package.json b/package.json new file mode 100644 index 00000000..001fd544 --- /dev/null +++ b/package.json @@ -0,0 +1,57 @@ +{ + "name": "netlify-react-cms", + "version": "0.0.1", + "description": "Netlify CMS lets content editors work on structured content stored in git", + "main": "index.js", + "scripts": { + "start": "webpack-dev-server -d --inline --config webpack.config.js", + "test": "NODE_ENV=test mocha --recursive --compilers js:babel-register --require ./test/setup.js", + "test:watch": "npm test -- --watch" + }, + "keywords": [ + "netlify", + "cms" + ], + "author": "Netlify", + "license": "MIT", + "devDependencies": { + "autoprefixer": "^6.3.3", + "babel-core": "^6.5.1", + "babel-eslint": "^4.1.8", + "babel-loader": "^6.2.2", + "babel-plugin-transform-class-properties": "^6.5.2", + "babel-plugin-transform-object-assign": "^6.5.0", + "babel-plugin-transform-object-rest-spread": "^6.5.0", + "babel-preset-es2015": "^6.5.0", + "babel-preset-react": "^6.5.0", + "babel-register": "^6.5.2", + "babel-runtime": "^6.5.0", + "eslint": "^1.10.3", + "eslint-loader": "^1.2.1", + "eslint-plugin-react": "^3.16.1", + "exports-loader": "^0.6.3", + "express": "^4.13.4", + "file-loader": "^0.8.5", + "immutable": "^3.7.6", + "imports-loader": "^0.6.5", + "js-yaml": "^3.5.3", + "mocha": "^2.4.5", + "normalizr": "^2.0.0", + "react": "^0.14.7", + "react-dom": "^0.14.7", + "react-immutable-proptypes": "^1.6.0", + "react-lazy-load": "^3.0.3", + "react-pure-render": "^1.0.2", + "react-redux": "^4.4.0", + "react-router": "^2.0.0", + "react-router-redux": "^3.0.0", + "redux": "^3.3.1", + "redux-thunk": "^1.0.3", + "style-loader": "^0.13.0", + "url-loader": "^0.5.7", + "webpack": "^1.12.13", + "webpack-dev-server": "^1.14.1", + "webpack-postcss-tools": "^1.1.1", + "whatwg-fetch": "^0.11.0" + } +} diff --git a/src/actions/config.js b/src/actions/config.js new file mode 100644 index 00000000..85141672 --- /dev/null +++ b/src/actions/config.js @@ -0,0 +1,60 @@ +import yaml from 'js-yaml'; + +export const CONFIG = { + REQUEST: 'REQUEST', + SUCCESS: 'SUCCESS', + FAILURE: 'FAILURE' +}; + +export function configLoaded(config) { + return { + type: CONFIG.SUCCESS, + payload: config + }; +} + +export function configLoading() { + return { + type: CONFIG.REQUEST + }; +} + +export function configFailed(err) { + return { + type: CONFIG.FAILURE, + error: 'Error loading config', + payload: err + }; +} + +export function loadConfig(config) { + if (window.CMS_CONFIG) { + return configLoaded(window.CMS_CONFIG); + } + return (dispatch, getState) => { + dispatch(configLoading()); + + fetch('config.yml').then((response) => { + if (response.status !== 200) { + throw `Failed to load config.yml (${response.status})`; + } + + response.text().then(parseConfig).then((config) => dispatch(configLoaded(config))); + }).catch((err) => { + dispatch(configFailed(err)); + }); + }; +} + +function parseConfig(data) { + const config = yaml.safeLoad(data); + + if (typeof CMS_ENV === 'string' && config[CMS_ENV]) { + for (var key in config[CMS_ENV]) { + if (config[CMS_ENV].hasOwnProperty(key)) { + config[key] = config[CMS_ENV][key]; + } + } + } + return config; +} diff --git a/src/containers/App.js b/src/containers/App.js new file mode 100644 index 00000000..2f001964 --- /dev/null +++ b/src/containers/App.js @@ -0,0 +1,58 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import { loadConfig } from '../actions/config'; + +class App extends React.Component { + constructor(props) { + super(props); + } + + componentDidMount() { + this.props.dispatch(loadConfig()); + } + + configError(config) { + return
+

Error loading the CMS configuration

+ +
+

The "config.yml" file could not be loaded or failed to parse properly.

+

Error message: {config.get('error')}

+
+
; + } + + configLoading() { + return
+

Loading configuration...

+
; + } + + render() { + const { config, children } = this.props; + + if (config === null) { + return null; + } + + if (config.get('error')) { + return this.configError(config); + } + + if (config.get('isFetching')) { + return this.configLoading(); + } + + return ( +
{children}
+ ); + } +} + +function mapStateToProps(state) { + return { + config: state.config + }; +} + +export default connect(mapStateToProps)(App); diff --git a/src/containers/DashboardPage.js b/src/containers/DashboardPage.js new file mode 100644 index 00000000..3c872da7 --- /dev/null +++ b/src/containers/DashboardPage.js @@ -0,0 +1,26 @@ +import React from 'react'; +import { Link } from 'react-router'; +import { connect } from 'react-redux'; + +class DashboardPage extends React.Component { + render() { + const { collections } = this.props; + + return
+

Dashboard

+ {collections && collections.map((collection) => ( +
+ {collection.get('name')} +
+ )).toArray()} +
; + } +} + +function mapStateToProps(state) { + return { + collections: state.collections + }; +} + +export default connect(mapStateToProps)(DashboardPage); diff --git a/src/index.js b/src/index.js new file mode 100644 index 00000000..7f0ba47c --- /dev/null +++ b/src/index.js @@ -0,0 +1,17 @@ +import React from 'react'; +import { render } from 'react-dom'; +import { Provider } from 'react-redux'; +import configureStore from './store/configureStore'; +import Routes from './routes/routes'; +import 'file?name=index.html!../example/index.html'; + +const store = configureStore(); + +const el = document.createElement('div'); +document.body.appendChild(el); + +render(( + + + +), el); diff --git a/src/reducers/collections.js b/src/reducers/collections.js new file mode 100644 index 00000000..cee6d770 --- /dev/null +++ b/src/reducers/collections.js @@ -0,0 +1,16 @@ +import Immutable from 'immutable'; +import { CONFIG } from '../actions/config'; + +export function collections(state = null, action) { + switch (action.type) { + case CONFIG.SUCCESS: + const collections = action.payload && action.payload.collections; + return Immutable.OrderedMap().withMutations((map) => { + (collections || []).forEach(function(collection) { + map.set(collection.name, Immutable.fromJS(collection)); + }); + }); + default: + return state; + } +} diff --git a/src/reducers/config.js b/src/reducers/config.js new file mode 100644 index 00000000..740c7db9 --- /dev/null +++ b/src/reducers/config.js @@ -0,0 +1,15 @@ +import Immutable from 'immutable'; +import { CONFIG } from '../actions/config'; + +export function config(state = null, action) { + switch (action.type) { + case CONFIG.REQUEST: + return Immutable.Map({isFetching: true}); + case CONFIG.SUCCESS: + return Immutable.fromJS(action.payload); + case CONFIG.FAILURE: + return Immutable.Map({error: action.payload.toString()}); + default: + return state; + } +} diff --git a/src/routes/routes.js b/src/routes/routes.js new file mode 100644 index 00000000..340c47cf --- /dev/null +++ b/src/routes/routes.js @@ -0,0 +1,12 @@ +import React from 'react'; +import { Router, Route, IndexRoute, browserHistory } from 'react-router'; +import App from '../containers/App'; +import DashboardPage from '../containers/DashboardPage'; + +export default () => ( + + + + + +); diff --git a/src/store/configureStore.js b/src/store/configureStore.js new file mode 100644 index 00000000..ffb31c87 --- /dev/null +++ b/src/store/configureStore.js @@ -0,0 +1,21 @@ +import { createStore, applyMiddleware, combineReducers, compose } from 'redux'; +import thunkMiddleware from 'redux-thunk'; +import { browserHistory } from 'react-router'; +import { syncHistory, routeReducer } from 'react-router-redux'; +import { config } from '../reducers/config'; +import { collections } from '../reducers/collections'; + +const reducer = combineReducers({ + config, + collections, + router: routeReducer +}); + +const createStoreWithMiddleware = compose( + applyMiddleware(thunkMiddleware, syncHistory(browserHistory)), + window.devToolsExtension ? window.devToolsExtension() : (f) => f +)(createStore); + +export default (initialState) => ( + createStoreWithMiddleware(reducer, initialState) +); diff --git a/test/reducers/collections.spec.js b/test/reducers/collections.spec.js new file mode 100644 index 00000000..a4678477 --- /dev/null +++ b/test/reducers/collections.spec.js @@ -0,0 +1,26 @@ +import expect from 'expect'; +import Immutable from 'immutable'; +import { configLoaded } from '../../src/actions/config'; +import { collections } from '../../src/reducers/collections'; + +describe('collections', () => { + it('should handle an empty state', () => { + expect( + collections(undefined, {}) + ).toEqual( + null + ); + }); + + it('should load the collections from the config', () => { + expect( + collections(undefined, configLoaded({collections: [ + {name: 'posts', folder: '_posts', fields: [{name: 'title', widget: 'string'}]} + ]})) + ).toEqual( + Immutable.OrderedMap({ + posts: Immutable.fromJS({name: 'posts', folder: '_posts', fields: [{name: 'title', widget: 'string'}]}) + }) + ); + }); +}); diff --git a/test/reducers/config.spec.js b/test/reducers/config.spec.js new file mode 100644 index 00000000..75aa9aad --- /dev/null +++ b/test/reducers/config.spec.js @@ -0,0 +1,38 @@ +import expect from 'expect'; +import Immutable from 'immutable'; +import { configLoaded, configLoading, configFailed } from '../../src/actions/config'; +import { config } from '../../src/reducers/config'; + +describe('config', () => { + it('should handle an empty state', () => { + expect( + config(undefined, {}) + ).toEqual( + null + ); + }); + + it('should handle an update', () => { + expect( + config(Immutable.Map({'a': 'b', 'c': 'd'}), configLoaded({'a': 'changed', 'e': 'new'})) + ).toEqual( + Immutable.Map({'a': 'changed', 'e': 'new'}) + ); + }); + + it('should mark the config as loading', () => { + expect( + config(undefined, configLoading()) + ).toEqual( + Immutable.Map({isFetching: true}) + ); + }); + + it('should handle an error', () => { + expect( + config(Immutable.Map({isFetching: true}), configFailed(new Error('Config could not be loaded'))) + ).toEqual( + Immutable.Map({error: 'Error: Config could not be loaded'}) + ); + }); +}); diff --git a/test/setup.js b/test/setup.js new file mode 100644 index 00000000..e69de29b diff --git a/webpack.config.js b/webpack.config.js new file mode 100644 index 00000000..a27565fa --- /dev/null +++ b/webpack.config.js @@ -0,0 +1,45 @@ +/* global module, __dirname, require */ +var webpack = require('webpack'); +var path = require('path'); + +module.exports = { + module: { + loaders: [ + { + test: /\.((png)|(eot)|(woff)|(woff2)|(ttf)|(svg)|(gif))(\?v=\d+\.\d+\.\d+)?$/, + loader: 'file?name=/[hash].[ext]' + }, + { + loader: 'babel', + test: /\.js?$/, + exclude: /(node_modules|bower_components)/, + query: { + cacheDirectory: true, + presets: ['react', 'es2015'], + plugins: ['transform-class-properties', 'transform-object-assign', 'transform-object-rest-spread'] + } + } + ] + }, + + plugins: [ + new webpack.ProvidePlugin({ + 'fetch': 'imports?this=>global!exports?global.fetch!whatwg-fetch' + }) + ], + + context: path.join(__dirname, 'src'), + entry: { + cms: './index', + }, + output: { + path: path.join(__dirname, 'dist'), + filename: '[name].js' + }, + externals: [/^vendor\/.+\.js$/], + devServer: { + contentBase: 'example/', + historyApiFallback: true, + devTool: 'source-map' + }, +};