diff --git a/example/index.html b/example/index.html index d343c456..effe5f93 100644 --- a/example/index.html +++ b/example/index.html @@ -2,9 +2,44 @@ This is an example + - diff --git a/package.json b/package.json index 001fd544..028dd68e 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "imports-loader": "^0.6.5", "js-yaml": "^3.5.3", "mocha": "^2.4.5", + "moment": "^2.11.2", "normalizr": "^2.0.0", "react": "^0.14.7", "react-dom": "^0.14.7", diff --git a/src/actions/config.js b/src/actions/config.js index 1bb3a346..15b567fb 100644 --- a/src/actions/config.js +++ b/src/actions/config.js @@ -32,7 +32,7 @@ export function loadConfig(config) { return (dispatch, getState) => { dispatch(configLoading()); - fetch('config.yml').then((response) => { + fetch('/config.yml').then((response) => { if (response.status !== 200) { throw `Failed to load config.yml (${response.status})`; } diff --git a/src/actions/entries.js b/src/actions/entries.js new file mode 100644 index 00000000..1789592a --- /dev/null +++ b/src/actions/entries.js @@ -0,0 +1,49 @@ +import { currentBackend } from '../backends/backend'; + +export const ENTRIES_REQUEST = 'ENTRIES_REQUEST'; +export const ENTRIES_SUCCESS = 'ENTRIES_SUCCESS'; +export const ENTRIES_FAILURE = 'ENTRIES_FAILURE'; + +export function entriesLoaded(collection, entries) { + return { + type: ENTRIES_SUCCESS, + payload: { + collection: collection.get('name'), + entries: entries + } + }; +} + +export function entriesLoading(collection) { + return { + type: ENTRIES_REQUEST, + payload: { + collection: collection.get('name') + } + }; +} + +export function entriesFailed(collection, error) { + return { + type: ENTRIES_FAILURE, + error: 'Failed to load entries', + payload: error.toString(), + meta: {collection: collection.get('name')} + }; +} + +export function loadEntries(collection) { + return (dispatch, getState) => { + if (collection.get('isFetching')) { return; } + const state = getState(); + const backend = currentBackend(state.config); + + dispatch(entriesLoading(collection)); + backend.entries(collection) + .then((entries) => dispatch(entriesLoaded(collection, entries))) + .catch((err) => { + console.error(err); + return dispatch(entriesFailed(collection, err)); + }); + }; +} diff --git a/src/backends/backend.js b/src/backends/backend.js index 073b96f6..8f99e507 100644 --- a/src/backends/backend.js +++ b/src/backends/backend.js @@ -1,4 +1,5 @@ import TestRepoBackend from './test-repo/Implementation'; +import { resolveFormat } from '../formats/formats'; export function resolveBackend(config) { const name = config.getIn(['backend', 'name']); @@ -32,8 +33,24 @@ class Backend { return this.implementation.authComponent(); } - authenticate(state) { - return this.implementation.authenticate(state); + authenticate(credentials) { + return this.implementation.authenticate(credentials); + } + + entries(collection) { + return this.implementation.entries(collection).then((entries) => ( + (entries || []).map((entry) => { + const format = resolveFormat(collection, entry); + if (entry && entry.raw) { + entry.data = format && format.fromFile(entry.raw); + } + return entry; + }) + )); + } + + entry(collection, slug) { + return this.implementation.entry(collection, slug); } } diff --git a/src/backends/test-repo/AuthenticationPage.js b/src/backends/test-repo/AuthenticationPage.js index 6e46d546..fd5c3ddc 100644 --- a/src/backends/test-repo/AuthenticationPage.js +++ b/src/backends/test-repo/AuthenticationPage.js @@ -12,7 +12,8 @@ export default class AuthenticationPage extends React.Component { this.handleEmailChange = this.handleEmailChange.bind(this); } - handleLogin() { + handleLogin(e) { + e.preventDefault(); this.props.onLogin(this.state); } @@ -21,13 +22,13 @@ export default class AuthenticationPage extends React.Component { } render() { - return
+ return

- +

-
; + ; } } diff --git a/src/backends/test-repo/implementation.js b/src/backends/test-repo/implementation.js index f029d873..510b7cd3 100644 --- a/src/backends/test-repo/implementation.js +++ b/src/backends/test-repo/implementation.js @@ -1,8 +1,16 @@ import AuthenticationPage from './AuthenticationPage'; +function getSlug(path) { + const m = path.match(/([^\/]+)(\.[^\/\.]+)?$/); + return m && m[1]; +} + export default class TestRepo { constructor(config) { this.config = config; + if (window.repoFiles == null) { + throw 'The TestRepo backend needs a "window.repoFiles" object.'; + } } authComponent() { @@ -12,4 +20,24 @@ export default class TestRepo { authenticate(state) { return Promise.resolve({email: state.email}); } + + entries(collection) { + const entries = []; + const folder = collection.get('folder'); + if (folder) { + for (var path in window.repoFiles[folder]) { + entries.push({ + path: folder + '/' + path, + slug: getSlug(path), + raw: window.repoFiles[folder][path].content + }); + } + } + + return Promise.resolve(entries); + } + + entry(collection, slug) { + return Promise.resolve({slug: slug, title: 'hello'}); + } } diff --git a/src/components/EntryListing.js b/src/components/EntryListing.js new file mode 100644 index 00000000..ab1ffd40 --- /dev/null +++ b/src/components/EntryListing.js @@ -0,0 +1,19 @@ +import React from 'react'; +import { Link } from 'react-router'; + +export default class EntryListing extends React.Component { + render() { + const { collection, entries } = this.props; + const name = collection.get('name'); + + return
+

Listing entries!

+ {entries.map((entry) => { + const path = `/collections/${name}/entries/${entry.get('slug')}`; + return +

{entry.getIn(['data', 'title'])}

+ ; + })} +
; + } +} diff --git a/src/containers/CollectionPage.js b/src/containers/CollectionPage.js new file mode 100644 index 00000000..cda3010d --- /dev/null +++ b/src/containers/CollectionPage.js @@ -0,0 +1,61 @@ +import React from 'react'; +import { Link } from 'react-router'; +import { connect } from 'react-redux'; +import { loadEntries } from '../actions/entries'; +import EntryListing from '../components/EntryListing'; + +class DashboardPage extends React.Component { + componentDidMount() { + const { collection, dispatch } = this.props; + + if (collection) { + dispatch(loadEntries(collection)); + } + } + + componentWillReceiveProps(nextProps) { + const { collection, dispatch } = this.props; + if (nextProps.collection !== collection) { + dispatch(loadEntries(nextProps.collection)); + } + } + + render() { + const { collections, collection, slug, children } = this.props; + + if (collections == null) { + return

No collections defined in your config.yml

; + } + + const entries = collection.get('entries'); + + return
+

Dashboard

+
+ {collections.map((collection) => ( +
+ {collection.get('name')} +
+ )).toArray()} +
+
+ {slug ? children : + entries ? : 'No entries...' + } +
+
; + } +} + +function mapStateToProps(state, ownProps) { + const { collections } = state; + const { name, slug } = ownProps.params; + + return { + slug: slug, + collection: name ? collections.get(name) : collections.first(), + collections: collections + }; +} + +export default connect(mapStateToProps)(DashboardPage); diff --git a/src/containers/DashboardPage.js b/src/containers/DashboardPage.js deleted file mode 100644 index 3c872da7..00000000 --- a/src/containers/DashboardPage.js +++ /dev/null @@ -1,26 +0,0 @@ -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/containers/EntryPage.js b/src/containers/EntryPage.js new file mode 100644 index 00000000..9e1d64bc --- /dev/null +++ b/src/containers/EntryPage.js @@ -0,0 +1,33 @@ +import React from 'react'; +import { Link } from 'react-router'; +import { connect } from 'react-redux'; +import { loadEntries } from '../actions/entries'; +import EntryListing from '../components/EntryListing'; + +class DashboardPage extends React.Component { + componentDidMount() { + } + + componentWillReceiveProps(nextProps) { + } + + render() { + const { collection, entry } = this.props; + + return
+

Entry in {collection.get('label')}

+

{entry && entry.get('title')}

+
; + } +} + +function mapStateToProps(state, ownProps) { + const { collections } = state; + + return { + collection: collections.get(ownProps.params.name), + collections: collections + }; +} + +export default connect(mapStateToProps)(DashboardPage); diff --git a/src/formats/formats.js b/src/formats/formats.js new file mode 100644 index 00000000..9d6f72ad --- /dev/null +++ b/src/formats/formats.js @@ -0,0 +1,5 @@ +import YAMLFrontmatter from './yaml-frontmatter'; + +export function resolveFormat(collection, entry) { + return new YAMLFrontmatter(); +} diff --git a/src/formats/yaml-frontmatter.js b/src/formats/yaml-frontmatter.js new file mode 100644 index 00000000..545cc56e --- /dev/null +++ b/src/formats/yaml-frontmatter.js @@ -0,0 +1,31 @@ +import YAML from './yaml'; + +const regexp = /^---\n([^]*?)\n---\n([^]*)$/; + +export default class YAMLFrontmatter { + fromFile(content) { + const match = content.match(regexp); + const obj = match ? new YAML().fromFile(match[1]) : {}; + obj.body = match ? (match[2] || '').replace(/^\n+/, '') : content; + return obj; + } + + toFile(data) { + const meta = {}; + let body = ''; + let content = ''; + for (var key in data) { + if (key === 'body') { + body = data[key]; + } else { + meta[key] = data[key]; + } + } + + content += '---\n'; + content += new YAML().toFile(meta); + content += '---\n\n'; + content += body; + return content; + } +} diff --git a/src/formats/yaml.js b/src/formats/yaml.js new file mode 100644 index 00000000..c0384e4b --- /dev/null +++ b/src/formats/yaml.js @@ -0,0 +1,31 @@ +import yaml from 'js-yaml'; +import moment from 'moment'; + +const MomentType = new yaml.Type('date', { + kind: 'scalar', + predicate: function(value) { + return moment.isMoment(value); + }, + represent: function(value) { + return value.format(value._f); + }, + resolve: function(value) { + return moment.isMoment(value) && value._f; + } +}); + +const OutputSchema = new yaml.Schema({ + include: yaml.DEFAULT_SAFE_SCHEMA.include, + implicit: [MomentType].concat(yaml.DEFAULT_SAFE_SCHEMA.implicit), + explicit: yaml.DEFAULT_SAFE_SCHEMA.explicit +}); + +export default class YAML { + fromFile(content) { + return yaml.safeLoad(content); + } + + toFile(data) { + return yaml.safeDump(data, {schema: OutputSchema}); + } +} diff --git a/src/reducers/collections.js b/src/reducers/collections.js index 5451dbbb..dc8a3f47 100644 --- a/src/reducers/collections.js +++ b/src/reducers/collections.js @@ -1,15 +1,20 @@ -import Immutable from 'immutable'; +import { OrderedMap, fromJS } from 'immutable'; import { CONFIG_SUCCESS } from '../actions/config'; +import { ENTRIES_REQUEST, ENTRIES_SUCCESS } from '../actions/entries'; export function collections(state = null, action) { switch (action.type) { case CONFIG_SUCCESS: const collections = action.payload && action.payload.collections; - return Immutable.OrderedMap().withMutations((map) => { + return OrderedMap().withMutations((map) => { (collections || []).forEach(function(collection) { - map.set(collection.name, Immutable.fromJS(collection)); + map.set(collection.name, fromJS(collection)); }); }); + case ENTRIES_REQUEST: + return state && state.setIn([action.payload.collection, 'isFetching'], true); + case ENTRIES_SUCCESS: + return state && state.setIn([action.payload.collection, 'entries'], fromJS(action.payload.entries)); default: return state; } diff --git a/src/routes/routes.js b/src/routes/routes.js index 98eed6d1..5e166eee 100644 --- a/src/routes/routes.js +++ b/src/routes/routes.js @@ -1,13 +1,17 @@ import React from 'react'; import { Router, Route, IndexRoute, browserHistory } from 'react-router'; import App from '../containers/App'; -import DashboardPage from '../containers/DashboardPage'; +import DashboardPage from '../containers/CollectionPage'; +import EntryPage from '../containers/EntryPage'; import NotFoundPage from '../containers/NotFoundPage'; export default () => ( + + + diff --git a/test/reducers/collections.spec.js b/test/reducers/collections.spec.js index a4678477..f3f31583 100644 --- a/test/reducers/collections.spec.js +++ b/test/reducers/collections.spec.js @@ -1,6 +1,7 @@ import expect from 'expect'; -import Immutable from 'immutable'; +import { Map, OrderedMap, fromJS } from 'immutable'; import { configLoaded } from '../../src/actions/config'; +import { entriesLoading, entriesLoaded } from '../../src/actions/entries'; import { collections } from '../../src/reducers/collections'; describe('collections', () => { @@ -18,8 +19,35 @@ describe('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'}]}) + OrderedMap({ + posts: fromJS({name: 'posts', folder: '_posts', fields: [{name: 'title', widget: 'string'}]}) + }) + ); + }); + + it('should mark entries as loading', () => { + const state = OrderedMap({ + 'posts': Map({name: 'posts'}) + }); + expect( + collections(state, entriesLoading(Map({name: 'posts'}))) + ).toEqual( + OrderedMap({ + 'posts': Map({name: 'posts', isFetching: true}) + }) + ); + }); + + it('should handle loaded entries', () => { + const state = OrderedMap({ + 'posts': Map({name: 'posts'}) + }); + const entries = [{slug: 'a', path: ''}, {slug: 'b', title: 'B'}]; + expect( + collections(state, entriesLoaded(Map({name: 'posts'}), entries)) + ).toEqual( + OrderedMap({ + 'posts': fromJS({name: 'posts', entries: entries}) }) ); });