diff --git a/example/config.yml b/example/config.yml index 3d5f55bf..15f01c74 100644 --- a/example/config.yml +++ b/example/config.yml @@ -36,7 +36,14 @@ collections: # A list of collections the CMS should be able to edit file: "_data/settings.json" description: "General Site Settings" fields: - - {label: "Global title", name: site_title, widget: "string"} + - {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" diff --git a/example/index.html b/example/index.html index 3edb2778..ffd26667 100644 --- a/example/index.html +++ b/example/index.html @@ -33,7 +33,7 @@ content: '{"site_title": "CMS Demo"}' }, "authors.yml": { - content: 'authors:\n - name: Mathias\n description: Co-founder @ Netlify\n' + content: 'authors:\n - name: Mathias\n description: Co-founder @ Netlify\n - name: Chris\n description: Co-founder @ Netlify\n' } } } @@ -87,7 +87,31 @@ } }); + var GeneralPreview = createClass({ + render: function() { + var entry = this.props.entry; + var title = entry.getIn(['data', 'site_title']); + var posts = entry.getIn(['data', 'posts']); + var thumb = posts && posts.get('thumb'); + + return h('div', {}, + h('h1', {}, title), + h('dl', {}, + h('dt', {}, 'Posts on Frontpage'), + h('dd', {}, posts && posts.get('front_limit') || '0'), + + h('dt', {}, 'Default Author'), + h('dd', {}, posts && posts.get('author') || 'None'), + + h('dt', {}, 'Default Thumbnail'), + h('dd', {}, thumb && h('img', {src: this.props.getMedia(thumb).toString()})) + ) + ); + } + }); + CMS.registerPreviewTemplate("posts", PostPreview); + CMS.registerPreviewTemplate("general", GeneralPreview); CMS.registerPreviewStyle("/example.css"); CMS.registerEditorComponent({ id: "youtube", diff --git a/package.json b/package.json index 9f123f68..4da9aac2 100644 --- a/package.json +++ b/package.json @@ -91,7 +91,6 @@ "@kadira/storybook": "^1.36.0", "autoprefixer": "^6.3.3", "bricks.js": "^1.7.0", - "textarea-caret-position": "^0.1.1", "dateformat": "^1.0.12", "fuzzy": "^0.1.1", "immutability-helper": "^2.0.0", @@ -120,6 +119,7 @@ "react-router": "^2.5.1", "react-router-redux": "^4.0.5", "react-simple-dnd": "^0.1.2", + "react-sortable": "^1.2.0", "react-toolbox": "^1.2.1", "react-topbar-progress-indicator": "^1.0.0", "react-waypoint": "^3.1.3", @@ -131,6 +131,7 @@ "semaphore": "^1.0.5", "slate": "^0.14.14", "slate-drop-or-paste-images": "^0.2.0", + "textarea-caret-position": "^0.1.1", "uuid": "^2.0.3", "whatwg-fetch": "^1.0.0" }, diff --git a/src/actions/entries.js b/src/actions/entries.js index c023b3b3..c8d3914a 100644 --- a/src/actions/entries.js +++ b/src/actions/entries.js @@ -181,13 +181,10 @@ export function loadEntry(entry, collection, slug) { const state = getState(); const backend = currentBackend(state.config); dispatch(entryLoading(collection, slug)); - let getPromise; - if (entry && entry.get('path') && entry.get('partial')) { - getPromise = backend.getEntry(entry.get('collection'), entry.get('slug'), entry.get('path')); - } else { - getPromise = backend.lookupEntry(collection, slug); - } - return getPromise.then(loadedEntry => dispatch(entryLoaded(collection, loadedEntry))); + return backend.getEntry(collection, slug) + .then(loadedEntry => ( + dispatch(entryLoaded(collection, loadedEntry)) + )); }; } diff --git a/src/backends/backend.js b/src/backends/backend.js index 5d566cba..b9b923a0 100644 --- a/src/backends/backend.js +++ b/src/backends/backend.js @@ -3,7 +3,7 @@ import GitHubBackend from './github/implementation'; import NetlifyGitBackend from './netlify-git/implementation'; import { resolveFormat } from '../formats/formats'; import { createEntry } from '../valueObjects/Entry'; -import { FILES, FOLDER } from '../constants/collectionTypes'; +import Collection from '../valueObjects/Collection'; class LocalStorageAuthStore { storageKey = 'nf-cms-user'; @@ -20,8 +20,9 @@ class LocalStorageAuthStore { const slugFormatter = (template, entryData) => { const date = new Date(); - return template.replace(/\{\{([^\}]+)\}\}/g, (_, name) => { - switch (name) { + const identifier = entryData.get('title', entryData.get('path')); + return template.replace(/\{\{([^\}]+)\}\}/g, (_, field) => { + switch (field) { case 'year': return date.getFullYear(); case 'month': @@ -29,10 +30,9 @@ const slugFormatter = (template, entryData) => { case 'day': return (`0${ date.getDate() }`).slice(-2); case 'slug': - const identifier = entryData.get('title', entryData.get('path')); - return identifier.trim().toLowerCase().replace(/[^a-z0-9\.\-\_]+/gi, '-'); + return identifier.trim().toLowerCase().replace(/[^a-z0-9\.\-_]+/gi, '-'); default: - return entryData.get(name); + return entryData.get(field); } }); }; @@ -42,7 +42,7 @@ class Backend { this.implementation = implementation; this.authStore = authStore; if (this.implementation === null) { - throw 'Cannot instantiate a Backend with no implementation'; + throw new Error('Cannot instantiate a Backend with no implementation'); } } @@ -53,6 +53,7 @@ class Backend { this.implementation.setUser(stored); return stored; } + return null; } authComponent() { @@ -67,49 +68,33 @@ class Backend { } listEntries(collection) { - const type = collection.get('type'); - if (type === FOLDER) { - return this.implementation.entriesByFolder(collection) + const collectionModel = new Collection(collection); + const listMethod = this.implementation[collectionModel.listMethod()]; + return listMethod.call(this.implementation, collection) .then(loadedEntries => ( - loadedEntries.map(loadedEntry => createEntry(collection.get('name'), loadedEntry.file.path.split('/').pop().replace(/\.[^\.]+$/, ''), loadedEntry.file.path, { raw: loadedEntry.data })) + loadedEntries.map(loadedEntry => createEntry( + collection.get('name'), + collectionModel.entrySlug(loadedEntry.file.path), + loadedEntry.file.path, + { raw: loadedEntry.data, label: loadedEntry.file.label } + )) )) .then(entries => ( { entries: entries.map(this.entryWithFormat(collection)), } )); - } else if (type === FILES) { - const collectionFiles = collection.get('files').map(collectionFile => ({ path: collectionFile.get('file'), label: collectionFile.get('label') })); - return this.implementation.entriesByFiles(collection, collectionFiles) - .then(loadedEntries => ( - loadedEntries.map(loadedEntry => createEntry(collection.get('name'), loadedEntry.file.path.split('/').pop().replace(/\.[^\.]+$/, ''), loadedEntry.file.path, { raw: loadedEntry.data, label: loadedEntry.file.label })) - )) - .then(entries => ( - { - entries: entries.map(this.entryWithFormat(collection)), - } - )); - } - return Promise.reject(`Couldn't process collection type ${ type }`); } - // We have the file path. Fetch and parse the file. - getEntry(collection, slug, path) { - return this.implementation.getEntry(collection, slug, path).then(this.entryWithFormat(collection)); - } - - // Will fetch the whole list of files from GitHub and load each file, then looks up for entry. - // (Files are persisted in local storage - only expensive on the first run for each file). - lookupEntry(collection, slug) { - const type = collection.get('type'); - if (type === FOLDER) { - return this.implementation.entriesByFolder(collection) - .then(loadedEntries => ( - loadedEntries.map(loadedEntry => createEntry(collection.get('name'), loadedEntry.file.path.split('/').pop().replace(/\.[^\.]+$/, ''), loadedEntry.file.path, { raw: loadedEntry.data })) + getEntry(collection, slug) { + return this.implementation.getEntry(collection, slug, new Collection(collection).entryPath(slug)) + .then(loadedEntry => this.entryWithFormat(collection, slug)(createEntry( + collection.get('name'), + slug, + loadedEntry.file.path, + { raw: loadedEntry.data, label: loadedEntry.file.label } )) - .then(response => response.filter(entry => entry.slug === slug)[0]) - .then(this.entryWithFormat(collection)); - } + ); } newEntry(collection) { @@ -120,49 +105,66 @@ class Backend { return (entry) => { const format = resolveFormat(collectionOrEntity, entry); if (entry && entry.raw) { - entry.data = format && format.fromFile(entry.raw); - return entry; - } else { - return format.fromFile(entry); + return Object.assign(entry, { data: format && format.fromFile(entry.raw) }); } + return format.fromFile(entry); }; } unpublishedEntries(page, perPage) { - return this.implementation.unpublishedEntries(page, perPage).then((response) => { - return { - pagination: response.pagination, - entries: response.entries.map(this.entryWithFormat('editorialWorkflow')), - }; - }); + return this.implementation.unpublishedEntries(page, perPage) + .then(loadedEntries => loadedEntries.filter(entry => entry !== null)) + .then(entries => ( + entries.map((loadedEntry) => { + const entry = createEntry('draft', loadedEntry.slug, loadedEntry.file.path, { raw: loadedEntry.data }); + entry.metaData = loadedEntry.metaData; + return entry; + }) + )) + .then(entries => ({ + pagination: 0, + entries: entries.map(this.entryWithFormat('editorialWorkflow')), + })); } unpublishedEntry(collection, slug) { - return this.implementation.unpublishedEntry(collection, slug).then(this.entryWithFormat(collection)); + return this.implementation.unpublishedEntry(collection, slug) + .then((loadedEntry) => { + const entry = createEntry('draft', loadedEntry.slug, loadedEntry.file.path, { raw: loadedEntry.data }); + entry.metaData = loadedEntry.metaData; + return entry; + }) + .then(this.entryWithFormat(collection, slug)); } persistEntry(config, collection, entryDraft, MediaFiles, options) { + const collectionModel = new Collection(collection); const newEntry = entryDraft.getIn(['entry', 'newRecord']) || false; const parsedData = { title: entryDraft.getIn(['entry', 'data', 'title'], 'No Title'), - description: entryDraft.getIn(['entry', 'data', 'description'], 'No Description'), + description: entryDraft.getIn(['entry', 'data', 'description'], 'No Description!'), }; const entryData = entryDraft.getIn(['entry', 'data']).toJS(); let entryObj; if (newEntry) { + if (!collectionModel.allowNewEntries()) { + throw (new Error('Not allowed to create new entries in this collection')); + } const slug = slugFormatter(collection.get('slug'), entryDraft.getIn(['entry', 'data'])); + const path = collectionModel.entryPath(slug); entryObj = { - path: `${ collection.get('folder') }/${ slug }.md`, + path, slug, - raw: this.entryToRaw(collection, entryData), + raw: this.entryToRaw(collection, Object.assign({ path }, entryData)), }; } else { + const path = entryDraft.getIn(['entry', 'path']); entryObj = { - path: entryDraft.getIn(['entry', 'path']), + path, slug: entryDraft.getIn(['entry', 'slug']), - raw: this.entryToRaw(collection, entryData), + raw: this.entryToRaw(collection, Object.assign({ path }, entryData)), }; } @@ -201,20 +203,20 @@ class Backend { export function resolveBackend(config) { const name = config.getIn(['backend', 'name']); if (name == null) { - throw 'No backend defined in configuration'; + throw new Error('No backend defined in configuration'); } const authStore = new LocalStorageAuthStore(); switch (name) { case 'test-repo': - return new Backend(new TestRepoBackend(config, slugFormatter), authStore); + return new Backend(new TestRepoBackend(config), authStore); case 'github': - return new Backend(new GitHubBackend(config, slugFormatter), authStore); + return new Backend(new GitHubBackend(config), authStore); case 'netlify-git': - return new Backend(new NetlifyGitBackend(config, slugFormatter), authStore); + return new Backend(new NetlifyGitBackend(config), authStore); default: - throw `Backend not found: ${ name }`; + throw new Error(`Backend not found: ${ name }`); } } diff --git a/src/backends/github/API.js b/src/backends/github/API.js index 606debbd..83c01043 100644 --- a/src/backends/github/API.js +++ b/src/backends/github/API.js @@ -154,7 +154,7 @@ export default class API { metaData = data; return this.readFile(data.objects.entry, null, data.branch); }) - .then(file => ({ metaData, file })) + .then(fileData => ({ metaData, fileData })) .catch((error) => { return null; }); diff --git a/src/backends/github/implementation.js b/src/backends/github/implementation.js index 5015ea25..eb7dddf1 100644 --- a/src/backends/github/implementation.js +++ b/src/backends/github/implementation.js @@ -1,5 +1,4 @@ import semaphore from 'semaphore'; -import { createEntry } from '../../valueObjects/Entry'; import AuthenticationPage from './AuthenticationPage'; import API from './API'; @@ -9,7 +8,7 @@ export default class GitHub { constructor(config) { this.config = config; if (config.getIn(['backend', 'repo']) == null) { - throw 'The GitHub backend needs a "repo" in the backend configuration.'; + throw new Error('The GitHub backend needs a "repo" in the backend configuration.'); } this.repo = config.getIn(['backend', 'repo']); this.branch = config.getIn(['backend', 'branch']) || 'master'; @@ -32,34 +31,41 @@ export default class GitHub { } entriesByFolder(collection) { - return this.api.listFiles(collection.get('folder')).then(files => this.entriesByFiles(collection, files)); + return this.api.listFiles(collection.get('folder')) + .then(this.fetchFiles); } - entriesByFiles(collection, files) { + entriesByFiles(collection) { + const files = collection.get('files').map(collectionFile => ({ + path: collectionFile.get('file'), + label: collectionFile.get('label'), + })); + return this.fetchFiles(files); + } + + fetchFiles = (files) => { const sem = semaphore(MAX_CONCURRENT_DOWNLOADS); const promises = []; files.forEach((file) => { - promises.push(new Promise((resolve, reject) => { - return sem.take(() => this.api.readFile(file.path, file.sha).then((data) => { - resolve( - { - file, - data, - } - ); + promises.push(new Promise((resolve, reject) => ( + sem.take(() => this.api.readFile(file.path, file.sha).then((data) => { + resolve({ file, data }); sem.leave(); }).catch((err) => { sem.leave(); reject(err); - })); - })); + })) + ))); }); return Promise.all(promises); - } + }; // Fetches a single entry. getEntry(collection, slug, path) { - return this.api.readFile(path).then(data => createEntry(collection, slug, path, { raw: data })); + return this.api.readFile(path).then(data => ({ + file: { path }, + data, + })); } persistEntry(entry, mediaFiles = [], options = {}) { @@ -72,16 +78,19 @@ export default class GitHub { const promises = []; branches.map((branch) => { promises.push(new Promise((resolve, reject) => { - const contentKey = branch.ref.split('refs/heads/cms/').pop(); - return sem.take(() => this.api.readUnpublishedBranchFile(contentKey).then((data) => { + const slug = branch.ref.split('refs/heads/cms/').pop(); + return sem.take(() => this.api.readUnpublishedBranchFile(slug).then((data) => { if (data === null || data === undefined) { resolve(null); sem.leave(); } else { - const entryPath = data.metaData.objects.entry; - const entry = createEntry('draft', contentKey, entryPath, { raw: data.file }); - entry.metaData = data.metaData; - resolve(entry); + const path = data.metaData.objects.entry; + resolve({ + slug, + file: { path }, + data: data.fileData, + metaData: data.metaData, + }); sem.leave(); } }).catch((err) => { @@ -91,21 +100,17 @@ export default class GitHub { })); }); return Promise.all(promises); - }).then((entries) => { - const filteredEntries = entries.filter(entry => entry !== null); - return { - pagination: 0, - entries: filteredEntries, - }; }); } unpublishedEntry(collection, slug) { - return this.unpublishedEntries().then(response => ( - response.entries.filter((entry) => { - return entry.metaData && entry.slug === slug; - })[0] - )); + return this.api.readUnpublishedBranchFile(slug) + .then(data => ({ + slug, + file: { path: data.metaData.objects.entry }, + data: data.fileData, + metaData: data.metaData, + })); } updateUnpublishedEntryStatus(collection, slug, newStatus) { diff --git a/src/backends/test-repo/implementation.js b/src/backends/test-repo/implementation.js index d2e8ec84..3e9b79f7 100644 --- a/src/backends/test-repo/implementation.js +++ b/src/backends/test-repo/implementation.js @@ -1,9 +1,12 @@ import AuthenticationPage from './AuthenticationPage'; -import { createEntry } from '../../valueObjects/Entry'; -function getSlug(path) { - const m = path.match(/([^\/]+?)(\.[^\/\.]+)?$/); - return m && m[1]; +function getFile(path) { + const segments = path.split('/'); + let obj = window.repoFiles; + while (obj && segments.length) { + obj = obj[segments.shift()]; + } + return obj; } export default class TestRepo { @@ -41,14 +44,22 @@ export default class TestRepo { return Promise.resolve(entries); } - entriesByFiles(collection, files) { - throw new Error('Not implemented yet'); + entriesByFiles(collection) { + const files = collection.get('files').map(collectionFile => ({ + path: collectionFile.get('file'), + label: collectionFile.get('label'), + })); + return Promise.all(files.map(file => ({ + file, + data: getFile(file.path).content, + }))); } - lookupEntry(collection, slug) { - return this.entries(collection).then(response => ( - response.entries.filter(entry => entry.slug === slug)[0] - )); + getEntry(collection, slug, path) { + return Promise.resolve({ + file: { path }, + data: getFile(path).content + }); } persistEntry(entry, mediaFiles = [], options) { diff --git a/src/components/MarkupItReactRenderer/index.js b/src/components/MarkupItReactRenderer/index.js index f5ede88b..c87e8c1b 100644 --- a/src/components/MarkupItReactRenderer/index.js +++ b/src/components/MarkupItReactRenderer/index.js @@ -1,6 +1,7 @@ import React, { PropTypes } from 'react'; import MarkupIt, { Syntax, BLOCKS, STYLES, ENTITIES } from 'markup-it'; import { omit } from 'lodash'; +import registry from '../../lib/registry'; const defaultSchema = { [BLOCKS.DOCUMENT]: 'article', @@ -44,43 +45,16 @@ function sanitizeProps(props) { return omit(props, notAllowedAttributes); } -function renderToken(schema, token, index = 0, key = '0') { - const type = token.get('type'); - const data = token.get('data'); - const text = token.get('text'); - const tokens = token.get('tokens'); - const nodeType = schema[type]; - key = `${ key }.${ index }`; - - // Only render if type is registered as renderer - if (typeof nodeType !== 'undefined') { - let children = null; - if (tokens.size) { - children = tokens.map((token, idx) => renderToken(schema, token, idx, key)); - } else if (type === 'text') { - children = text; - } - if (nodeType !== null) { - let props = { key, token }; - if (typeof nodeType !== 'function') { - props = { key, ...sanitizeProps(data.toJS()) }; - } - // If this is a react element - return React.createElement(nodeType, props, children); - } else { - // If this is a text node - return children; - } - } - return null; -} - export default class MarkupItReactRenderer extends React.Component { constructor(props) { super(props); const { syntax } = props; this.parser = new MarkupIt(syntax); + this.plugins = {}; + registry.getEditorComponents().forEach((component) => { + this.plugins[component.get('id')] = component; + }); } componentWillReceiveProps(nextProps) { @@ -89,10 +63,51 @@ export default class MarkupItReactRenderer extends React.Component { } } + renderToken(schema, token, index = 0, key = '0') { + const type = token.get('type'); + const data = token.get('data'); + const text = token.get('text'); + const tokens = token.get('tokens'); + const nodeType = schema[type]; + key = `${ key }.${ index }`; + + // Only render if type is registered as renderer + if (typeof nodeType !== 'undefined') { + let children = null; + if (tokens.size) { + children = tokens.map((token, idx) => this.renderToken(schema, token, idx, key)); + } else if (type === 'text') { + children = text; + } + if (nodeType !== null) { + let props = { key, token }; + if (typeof nodeType !== 'function') { + props = { key, ...sanitizeProps(data.toJS()) }; + } + // If this is a react element + return React.createElement(nodeType, props, children); + } else { + // If this is a text node + return children; + } + } + + const plugin = this.plugins[token.get('type')]; + if (plugin) { + const output = plugin.toPreview(token.get('data').toJS()); + return typeof output === 'string' ? + : + output; + } + + return null; + } + + render() { const { value, schema } = this.props; const content = this.parser.toContent(value); - return renderToken({ ...defaultSchema, ...schema }, content.get('token')); + return this.renderToken({ ...defaultSchema, ...schema }, content.get('token')); } } diff --git a/src/components/PreviewPane/PreviewPane.js b/src/components/PreviewPane/PreviewPane.js index f3c99de2..1a297bef 100644 --- a/src/components/PreviewPane/PreviewPane.js +++ b/src/components/PreviewPane/PreviewPane.js @@ -3,6 +3,7 @@ import ReactDOM from 'react-dom'; import ImmutablePropTypes from 'react-immutable-proptypes'; import { ScrollSyncPane } from '../ScrollSync'; import registry from '../../lib/registry'; +import Collection from '../../valueObjects/Collection'; import { resolveWidget } from '../Widgets'; import Preview from './Preview'; import styles from './PreviewPane.css'; @@ -26,7 +27,9 @@ export default class PreviewPane extends React.Component { }; renderPreview() { - const component = registry.getPreviewTemplate(this.props.collection.get('name')) || Preview; + const { entry, collection } = this.props; + const collectionModel = new Collection(collection); + const component = registry.getPreviewTemplate(collectionModel.templateName(entry.get('slug'))) || Preview; const previewProps = { ...this.props, widgetFor: this.widgetFor, diff --git a/src/components/Widgets.js b/src/components/Widgets.js index 74bd97ec..bdecd996 100644 --- a/src/components/Widgets.js +++ b/src/components/Widgets.js @@ -3,6 +3,8 @@ import UnknownControl from './Widgets/UnknownControl'; import UnknownPreview from './Widgets/UnknownPreview'; import StringControl from './Widgets/StringControl'; import StringPreview from './Widgets/StringPreview'; +import NumberControl from './Widgets/NumberControl'; +import NumberPreview from './Widgets/NumberPreview'; import ListControl from './Widgets/ListControl'; import ListPreview from './Widgets/ListPreview'; import TextControl from './Widgets/TextControl'; @@ -13,13 +15,17 @@ import ImageControl from './Widgets/ImageControl'; import ImagePreview from './Widgets/ImagePreview'; import DateTimeControl from './Widgets/DateTimeControl'; import DateTimePreview from './Widgets/DateTimePreview'; +import ObjectControl from './Widgets/ObjectControl'; +import ObjectPreview from './Widgets/ObjectPreview'; registry.registerWidget('string', StringControl, StringPreview); registry.registerWidget('text', TextControl, TextPreview); +registry.registerWidget('number', NumberControl, NumberPreview); registry.registerWidget('list', ListControl, ListPreview); registry.registerWidget('markdown', MarkdownControl, MarkdownPreview); registry.registerWidget('image', ImageControl, ImagePreview); registry.registerWidget('datetime', DateTimeControl, DateTimePreview); +registry.registerWidget('object', ObjectControl, ObjectPreview); registry.registerWidget('unknown', UnknownControl, UnknownPreview); export function resolveWidget(name) { diff --git a/src/components/Widgets/ListControl.css b/src/components/Widgets/ListControl.css new file mode 100644 index 00000000..8fab091e --- /dev/null +++ b/src/components/Widgets/ListControl.css @@ -0,0 +1,72 @@ +:global(.list-item-dragging) { + opacity: 0.5; +} + +.addButton { + display: block; + cursor: pointer; + margin: 20px 0; + border: none; + background: transparent; + &::before { + content: "+"; + display: inline-block; + margin-right: 5px; + width: 15px; + height: 15px; + border: 1px solid #444; + border-radius: 100%; + background: transparent; + line-height: 13px; + } +} + +.removeButton { + position: absolute; + top: 5px; + right: 5px; + display: inline-block; + cursor: pointer; + border: none; + background: transparent; + width: 15px; + height: 15px; + border: 1px solid #444; + border-radius: 100%; + line-height: 13px; +} + +.toggleButton { + position: absolute; + top: 5px; + left: 5px; +} + +.item { + position: relative; + padding-left: 20px; + cursor: move; +} + +.objectLabel { + border: 1px solid #e8eae8; + margin-bottom: 20px; + padding: 20px; + display: none; +} + +.objectControl { + display: block; +} + +.expanded { +} + +.collapsed { + & .objectLabel { + display: block; + } + & .objectControl { + display: none; + } +} diff --git a/src/components/Widgets/ListControl.js b/src/components/Widgets/ListControl.js index 7d58de07..62c634b5 100644 --- a/src/components/Widgets/ListControl.js +++ b/src/components/Widgets/ListControl.js @@ -1,17 +1,132 @@ import React, { Component, PropTypes } from 'react'; +import { List, Map, fromJS } from 'immutable'; +import { sortable } from 'react-sortable'; +import ObjectControl from './ObjectControl'; +import styles from './ListControl.css'; + +function ListItem(props) { + return
{props.children}
; +} +ListItem.propTypes = { + className: PropTypes.string, + children: PropTypes.node, +}; +ListItem.displayName = 'list-item'; + +const SortableListItem = sortable(ListItem); export default class ListControl extends Component { - static propTypes = { onChange: PropTypes.func.isRequired, value: PropTypes.node, + field: PropTypes.node, }; + + constructor(props) { + super(props); + this.state = { itemStates: Map() }; + } + handleChange = (e) => { this.props.onChange(e.target.value.split(',').map(item => item.trim())); }; + handleAdd = (e) => { + e.preventDefault(); + const { value, onChange } = this.props; + + onChange((value || List()).push(Map())); + }; + + handleChangeFor(index) { + return (newValue) => { + const { value, onChange } = this.props; + onChange(value.set(index, newValue)); + }; + } + + handleRemove(index) { + return (e) => { + e.preventDefault(); + const { value, onChange } = this.props; + onChange(value.remove(index)); + }; + } + + handleToggle(index) { + return (e) => { + e.preventDefault(); + const { itemStates } = this.state; + this.setState({ + itemStates: itemStates.setIn([index, 'collapsed'], !itemStates.getIn([index, 'collapsed'])), + }); + }; + } + + objectLabel(item) { + const { field } = this.props; + const fields = field.get('fields'); + const first = fields.first(); + const value = item.get(first.get('name')); + return value || `No ${ first.get('name') }`; + } + + handleSort = (obj) => { + this.setState({ draggingIndex: obj.draggingIndex }); + if ('items' in obj) { + this.props.onChange(fromJS(obj.items)); + } + }; + + renderItem(item, index) { + const { value, field, getMedia, onAddMedia, onRemoveMedia } = this.props; + const { itemStates, draggedItem } = this.state; + const collapsed = itemStates.getIn([index, 'collapsed']); + const classNames = [styles.item, collapsed ? styles.collapsed : styles.expanded]; + + return ( +
+
{this.objectLabel(item)}
+
+ +
+ + +
+
); + } + + renderListControl() { + const { value, field } = this.props; + return (
+ {value && value.map((item, index) => this.renderItem(item, index))} +
+
); + } + render() { - const { value } = this.props; + const { value, field } = this.props; + + if (field.get('fields')) { + return this.renderListControl(); + } + return ; } } diff --git a/src/components/Widgets/ListPreview.js b/src/components/Widgets/ListPreview.js index 1b68a1ec..ed9542ca 100644 --- a/src/components/Widgets/ListPreview.js +++ b/src/components/Widgets/ListPreview.js @@ -1,11 +1,33 @@ -import React, { PropTypes } from 'react'; +import React, { PropTypes, Component } from 'react'; +import { resolveWidget } from '../Widgets'; -export default function ListPreview({ value }) { - return (); +export default class ObjectPreview extends Component { + widgetFor = (field, value) => { + const { getMedia } = this.props; + const widget = resolveWidget(field.get('widget')); + return (
{React.createElement(widget.preview, { + key: field.get('name'), + value: value && value.get(field.get('name')), + field, + getMedia, + })}
); + }; + + render() { + const { field, value } = this.props; + const fields = field && field.get('fields'); + if (fields) { + return value ? (
{value.map((val, index) =>
+ {fields && fields.map(f => this.widgetFor(f, val))} +
)}
) : null; + } + + return value ? value.join(', ') : null; + } } -ListPreview.propTypes = { +ObjectPreview.propTypes = { value: PropTypes.node, + field: PropTypes.node, + getMedia: PropTypes.func.isRequired, }; diff --git a/src/components/Widgets/MarkdownControlElements/RawEditor/BlockMenu.css b/src/components/Widgets/MarkdownControlElements/RawEditor/BlockMenu.css index 5183a9e7..6f602ca7 100644 --- a/src/components/Widgets/MarkdownControlElements/RawEditor/BlockMenu.css +++ b/src/components/Widgets/MarkdownControlElements/RawEditor/BlockMenu.css @@ -3,6 +3,7 @@ left: -18px; display: none; width: 100%; + z-index: 1000; } .visible { diff --git a/src/components/Widgets/MarkdownControlElements/RawEditor/index.js b/src/components/Widgets/MarkdownControlElements/RawEditor/index.js index bcc7c673..d8ad8eb3 100644 --- a/src/components/Widgets/MarkdownControlElements/RawEditor/index.js +++ b/src/components/Widgets/MarkdownControlElements/RawEditor/index.js @@ -65,18 +65,17 @@ function getCleanPaste(e) { }); } -const buildtInPlugins = fromJS([{ +const buildtInPlugins = [{ label: 'Image', id: 'image', - fromBlock: (data) => { - const m = data.match(/^!\[([^\]]+)\]\(([^\)]+)\)$/); - return m && { - image: m[2], - alt: m[1], - }; + fromBlock: match => match && { + image: match[2], + alt: match[1], }, toBlock: data => `![${ data.alt }](${ data.image })`, - toPreview: data => `${ data.alt }`, + toPreview: (data) => { + return {data.alt}; + }, pattern: /^!\[([^\]]+)\]\(([^\)]+)\)$/, fields: [{ label: 'Image', @@ -86,18 +85,20 @@ const buildtInPlugins = fromJS([{ label: 'Alt Text', name: 'alt', }], -}]); +}]; +buildtInPlugins.forEach(plugin => registry.registerEditorComponent(plugin)); export default class RawEditor extends React.Component { constructor(props) { super(props); const plugins = registry.getEditorComponents(); this.state = { - plugins: buildtInPlugins.concat(plugins), + plugins: plugins, }; this.shortcuts = { meta: { b: this.handleBold, + i: this.handleItalic, }, }; } @@ -160,7 +161,7 @@ export default class RawEditor extends React.Component { } replaceSelection(chars) { - const { value } = this.props; + const value = this.props.value || ''; const selection = this.getSelection(); const newSelection = Object.assign({}, selection); const beforeSelection = value.substr(0, selection.start); @@ -171,7 +172,7 @@ export default class RawEditor extends React.Component { } toggleHeader(header) { - const { value } = this.props; + const value = this.props.value || ''; const selection = this.getSelection(); const newSelection = Object.assign({}, selection); const lastNewline = value.lastIndexOf('\n', selection.start); @@ -233,7 +234,7 @@ export default class RawEditor extends React.Component { }; handleSelection = () => { - const { value } = this.props; + const value = this.props.value || ''; const selection = this.getSelection(); if (selection.start !== selection.end && !HAS_LINE_BREAK.test(selection.selected)) { try { diff --git a/src/components/Widgets/NumberControl.js b/src/components/Widgets/NumberControl.js new file mode 100644 index 00000000..fe92b768 --- /dev/null +++ b/src/components/Widgets/NumberControl.js @@ -0,0 +1,16 @@ +import React, { PropTypes } from 'react'; + +export default class StringControl extends React.Component { + handleChange = e => { + this.props.onChange(e.target.value); + }; + + render() { + return ; + } +} + +StringControl.propTypes = { + onChange: PropTypes.func.isRequired, + value: PropTypes.node, +}; diff --git a/src/components/Widgets/NumberPreview.js b/src/components/Widgets/NumberPreview.js new file mode 100644 index 00000000..972e068c --- /dev/null +++ b/src/components/Widgets/NumberPreview.js @@ -0,0 +1,9 @@ +import React, { PropTypes } from 'react'; + +export default function StringPreview({ value }) { + return {value}; +} + +StringPreview.propTypes = { + value: PropTypes.node, +}; diff --git a/src/components/Widgets/ObjectControl.css b/src/components/Widgets/ObjectControl.css new file mode 100644 index 00000000..f9c6a947 --- /dev/null +++ b/src/components/Widgets/ObjectControl.css @@ -0,0 +1,6 @@ +.root { + position: relative; + border: 1px solid #e8eae8; + margin-bottom: 20px; + padding: 20px; +} diff --git a/src/components/Widgets/ObjectControl.js b/src/components/Widgets/ObjectControl.js new file mode 100644 index 00000000..4c7ed706 --- /dev/null +++ b/src/components/Widgets/ObjectControl.js @@ -0,0 +1,52 @@ +import React, { Component, PropTypes } from 'react'; +import { Map } from 'immutable'; +import { resolveWidget } from '../Widgets'; +import controlStyles from '../ControlPanel/ControlPane.css'; +import styles from './ObjectControl.css'; + +export default class ObjectControl extends Component { + static propTypes = { + onChange: PropTypes.func.isRequired, + onAddMedia: PropTypes.func.isRequired, + getMedia: PropTypes.func.isRequired, + value: PropTypes.node, + field: PropTypes.node, + }; + + controlFor(field) { + const { onAddMedia, onRemoveMedia, getMedia, value, onChange } = this.props; + const widget = resolveWidget(field.get('widget') || 'string'); + const fieldValue = value && value.get(field.get('name')); + + return (
+
+ + { + React.createElement(widget.control, { + field, + value: fieldValue, + onChange: (val) => { + onChange((value || Map()).set(field.get('name'), val)); + }, + onAddMedia, + onRemoveMedia, + getMedia, + }) + } +
+
); + } + + render() { + const { field } = this.props; + const fields = field.get('fields'); + + if (!fields) { + return

No fields defined for this widget

; + } + + return (
+ {field.get('fields').map(field => this.controlFor(field))} +
); + } +} diff --git a/src/components/Widgets/ObjectPreview.js b/src/components/Widgets/ObjectPreview.js new file mode 100644 index 00000000..3c541ef6 --- /dev/null +++ b/src/components/Widgets/ObjectPreview.js @@ -0,0 +1,28 @@ +import React, { PropTypes, Component } from 'react'; +import { resolveWidget } from '../Widgets'; + +export default class ObjectPreview extends Component { + widgetFor = (field) => { + const { value, getMedia } = this.props; + const widget = resolveWidget(field.get('widget')); + return (
{React.createElement(widget.preview, { + key: field.get('name'), + value: value && value.get(field.get('name')), + field, + getMedia, + })}
); + }; + + render() { + const { field } = this.props; + const fields = field && field.get('fields'); + + return
{fields && fields.map(f => this.widgetFor(f))}
; + } +} + +ObjectPreview.propTypes = { + value: PropTypes.node, + field: PropTypes.node, + getMedia: PropTypes.func.isRequired, +}; diff --git a/src/containers/EntryPage.js b/src/containers/EntryPage.js index 20c7289c..b6fda5b1 100644 --- a/src/containers/EntryPage.js +++ b/src/containers/EntryPage.js @@ -12,7 +12,7 @@ import { import { cancelEdit } from '../actions/editor'; import { addMedia, removeMedia } from '../actions/media'; import { selectEntry, getMedia } from '../reducers'; -import { FOLDER, FILES } from '../constants/collectionTypes'; +import Collection from '../valueObjects/Collection'; import EntryEditor from '../components/EntryEditor/EntryEditor'; import entryPageHOC from './editorialWorkflow/EntryPageHOC'; import { Loader } from '../components/UI'; @@ -39,11 +39,10 @@ class EntryPage extends React.Component { componentDidMount() { const { entry, newEntry, collection, slug, createEmptyDraft, loadEntry } = this.props; - if (newEntry) { createEmptyDraft(collection); } else { - if (collection.get('type') === FOLDER) loadEntry(entry, collection, slug); + loadEntry(entry, collection, slug); this.createDraft(entry); } } @@ -108,15 +107,7 @@ function mapStateToProps(state, ownProps) { const { collections, entryDraft } = state; const slug = ownProps.params.slug; const collection = collections.get(ownProps.params.name); - - let fields; - if (collection.get('type') === FOLDER) { - fields = collection.get('fields'); - } else { - const files = collection.get('files'); - const file = files.filter(f => f.get('name') === slug); - fields = file.getIn([0, 'fields']); - } + const collectionModel = new Collection(collection); const newEntry = ownProps.route && ownProps.route.newRecord === true; const entry = newEntry ? null : selectEntry(state, collection.get('name'), slug); @@ -127,7 +118,7 @@ function mapStateToProps(state, ownProps) { newEntry, entryDraft, boundGetMedia, - fields, + fields: collectionModel.entryFields(slug), slug, entry, }; diff --git a/src/formats/formats.js b/src/formats/formats.js index fc8f91b1..48fa5367 100644 --- a/src/formats/formats.js +++ b/src/formats/formats.js @@ -1,7 +1,9 @@ import YAML from './yaml'; +import JSONFormatter from './json'; import YAMLFrontmatter from './yaml-frontmatter'; const yamlFormatter = new YAML(); +const jsonFormatter = new JSONFormatter(); const YamlFrontmatterFormatter = new YAMLFrontmatter(); function formatByType(type) { @@ -12,17 +14,18 @@ function formatByType(type) { function formatByExtension(extension) { return { - 'yml': yamlFormatter, - 'md': YamlFrontmatterFormatter, - 'markdown': YamlFrontmatterFormatter, - 'html': YamlFrontmatterFormatter + yml: yamlFormatter, + json: jsonFormatter, + md: YamlFrontmatterFormatter, + markdown: YamlFrontmatterFormatter, + html: YamlFrontmatterFormatter, }[extension] || YamlFrontmatterFormatter; } function formatByName(name) { return { - 'yaml': yamlFormatter, - 'frontmatter': YamlFrontmatterFormatter + yaml: yamlFormatter, + frontmatter: YamlFrontmatterFormatter, }[name] || YamlFrontmatterFormatter; } @@ -30,8 +33,9 @@ export function resolveFormat(collectionOrEntity, entry) { if (typeof collectionOrEntity === 'string') { return formatByType(collectionOrEntity); } - if (entry && entry.path) { - return formatByExtension(entry.path.split('.').pop()); + const path = entry && entry.path; + if (path) { + return formatByExtension(path.split('.').pop()); } return formatByName(collectionOrEntity.get('format')); } diff --git a/src/formats/json.js b/src/formats/json.js new file mode 100644 index 00000000..e2e5467e --- /dev/null +++ b/src/formats/json.js @@ -0,0 +1,9 @@ +export default class JSONFormatter { + fromFile(content) { + return JSON.parse(content); + } + + toFile(data) { + return JSON.stringify(data); + } +} diff --git a/src/index.js b/src/index.js index a9dc35ad..daa0530f 100644 --- a/src/index.js +++ b/src/index.js @@ -34,7 +34,6 @@ if (process.env.NODE_ENV !== 'production' && module.hot) { } window.CMS = {}; -console.log('reg: ', registry); for (const method in registry) { window.CMS[method] = registry[method]; } diff --git a/src/integrations/providers/algolia/implementation.js b/src/integrations/providers/algolia/implementation.js index 4eb90d2c..034c5e49 100644 --- a/src/integrations/providers/algolia/implementation.js +++ b/src/integrations/providers/algolia/implementation.js @@ -1,5 +1,6 @@ import { createEntry } from '../../../valueObjects/Entry'; import _ from 'lodash'; +import Collection from '../../../valueObjects/Collection'; function getSlug(path) { const m = path.match(/([^\/]+?)(\.[^\/\.]+)?$/); @@ -101,11 +102,12 @@ export default class Algolia { if (this.entriesCache.collection === collection && this.entriesCache.page === page) { return Promise.resolve({ page: this.entriesCache.page, entries: this.entriesCache.entries }); } else { + const collectionModel = new Collection(collection); return this.request(`${ this.searchURL }/indexes/${ collection.get('name') }`, { params: { page }, }).then((response) => { const entries = response.hits.map((hit) => { - const slug = hit.slug || getSlug(hit.path); + const slug = collectionModel.entrySlug(hit.path); return createEntry(collection.get('name'), slug, hit.path, { data: hit.data, partial: true }); }); this.entriesCache = { collection, pagination: response.page, entries }; diff --git a/src/reducers/entries.js b/src/reducers/entries.js index 8e5b36ca..f29c0d90 100644 --- a/src/reducers/entries.js +++ b/src/reducers/entries.js @@ -31,7 +31,6 @@ const entries = (state = Map({ entities: Map(), pages: Map() }), action) => { collection = action.payload.collection; loadedEntries = action.payload.entries; page = action.payload.page; - return state.withMutations((map) => { loadedEntries.forEach(entry => ( map.setIn(['entities', `${ collection }.${ entry.slug }`], fromJS(entry).set('isFetching', false)) diff --git a/src/valueObjects/Collection.js b/src/valueObjects/Collection.js new file mode 100644 index 00000000..d4daaa64 --- /dev/null +++ b/src/valueObjects/Collection.js @@ -0,0 +1,121 @@ +import { FOLDER, FILES } from '../constants/collectionTypes'; + +function formatToExtension(format) { + return { + markdown: 'md', + yaml: 'yml', + json: 'json', + html: 'html', + }[format]; +} + +class FolderCollection { + constructor(collection) { + this.collection = collection; + } + + entryFields() { + return this.collection.get('fields'); + } + + entryPath(slug) { + return `${ this.collection.get('folder') }/${ slug }.${ this.entryExtension() }`; + } + + entrySlug(path) { + return path.split('/').pop().replace(/\.[^\.]+$/, ''); + } + + listMethod() { + return 'entriesByFolder'; + } + + entryExtension() { + return this.collection.get('extension') || formatToExtension(this.collection.get('format') || 'markdown'); + } + + allowNewEntries() { + return this.collection.get('create'); + } + + templateName() { + return this.collection.get('name'); + } +} + +class FilesCollection { + constructor(collection) { + this.collection = collection; + } + + entryFields(slug) { + const file = this.fileForEntry(slug); + return file && file.get('fields'); + } + + entryPath(slug) { + const file = this.fileForEntry(slug); + return file && file.get('file'); + } + + entrySlug(path) { + const file = this.collection.get('files').filter(f => f.get('file') === path).get(0); + return file && file.get('name'); + } + + fileForEntry(slug) { + const files = this.collection.get('files'); + return files.filter(f => f.get('name') === slug).get(0); + } + + listMethod() { + return 'entriesByFiles'; + } + + allowNewEntries() { + return false; + } + + templateName(slug) { + return slug; + } +} + +export default class Collection { + constructor(collection) { + switch (collection.get('type')) { + case FOLDER: + this.collection = new FolderCollection(collection); + break; + case FILES: + this.collection = new FilesCollection(collection); + break; + default: + throw ('Unknown collection type: %o', collection.get('type')); + } + } + + entryFields(slug) { + return this.collection.entryFields(slug); + } + + entryPath(slug) { + return this.collection.entryPath(slug); + } + + entrySlug(path) { + return this.collection.entrySlug(path); + } + + listMethod() { + return this.collection.listMethod(); + } + + allowNewEntries() { + return this.collection.allowNewEntries(); + } + + templateName(slug) { + return this.collection.templateName(slug); + } +}