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