Merge pull request #82 from netlify/markitup-react

Entry Editor Improvements
This commit is contained in:
Cássio Souza 2016-10-18 12:22:58 -02:00 committed by GitHub
commit 009c881290
61 changed files with 1852 additions and 913 deletions

View File

@ -0,0 +1,3 @@
// See http://facebook.github.io/jest/docs/tutorial-webpack.html#content
module.exports = 'test-file-stub';

View File

@ -0,0 +1,3 @@
// See http://facebook.github.io/jest/docs/tutorial-webpack.html#content
module.exports = {};

View File

@ -32,6 +32,13 @@
]
},
"pre-commit": "lint:staged",
"jest": {
"moduleNameMapper": {
"^.+\\.(png|eot|woff|woff2|ttf|svg|gif)$": "<rootDir>/__mocks__/fileLoaderMock.js",
"^.+\\.scss$": "<rootDir>/__mocks__/styleLoaderMock.js",
"^.+\\.css$": "identity-obj-proxy"
}
},
"keywords": [
"netlify",
"cms"
@ -39,8 +46,8 @@
"author": "Netlify",
"license": "MIT",
"devDependencies": {
"@kadira/storybook": "^1.36.0",
"babel-core": "^6.5.1",
"babel-jest": "^15.0.0",
"babel-loader": "^6.2.2",
"babel-plugin-lodash": "^3.2.0",
"babel-plugin-transform-class-properties": "^6.5.2",
@ -50,11 +57,14 @@
"babel-preset-react": "^6.5.0",
"babel-runtime": "^6.5.0",
"css-loader": "^0.23.1",
"enzyme": "^2.4.1",
"eslint": "^3.7.1",
"eslint-config-netlify": "github:netlify/eslint-config-netlify",
"expect": "^1.20.2",
"exports-loader": "^0.6.3",
"file-loader": "^0.8.5",
"fsevents": "^1.0.14",
"identity-obj-proxy": "^3.0.0",
"imports-loader": "^0.6.5",
"jest-cli": "^16.0.1",
"lint-staged": "^3.1.0",
@ -64,6 +74,7 @@
"postcss-import": "^8.1.2",
"postcss-loader": "^0.9.1",
"pre-commit": "^1.1.3",
"react-addons-test-utils": "^15.3.2",
"sass-loader": "^4.0.2",
"style-loader": "^0.13.0",
"stylefmt": "^4.3.1",
@ -79,6 +90,7 @@
"webpack-postcss-tools": "^1.1.1"
},
"dependencies": {
"@kadira/storybook": "^1.36.0",
"autoprefixer": "^6.3.3",
"bricks.js": "^1.7.0",
"dateformat": "^1.0.12",
@ -112,10 +124,12 @@
"react-toolbox": "^1.2.1",
"react-waypoint": "^3.1.3",
"redux": "^3.3.1",
"redux-notifications": "^2.1.1",
"redux-thunk": "^1.0.3",
"selection-position": "^1.0.0",
"semaphore": "^1.0.5",
"slate": "^0.13.6",
"slate": "^0.14.14",
"slate-drop-or-paste-images": "^0.2.0",
"whatwg-fetch": "^1.0.0"
}
}

View File

@ -1,8 +1,16 @@
import history from '../routing/history';
export const SWITCH_VISUAL_MODE = 'SWITCH_VISUAL_MODE';
export function switchVisualMode(useVisualMode) {
return {
type: SWITCH_VISUAL_MODE,
payload: useVisualMode
payload: useVisualMode,
};
}
export function cancelEdit() {
return () => {
history.goBack();
};
}

View File

@ -1,7 +1,10 @@
import { actions as notifActions } from 'redux-notifications';
import { currentBackend } from '../backends/backend';
import { getIntegrationProvider } from '../integrations';
import { getMedia, selectIntegration } from '../reducers';
const { notifSend } = notifActions;
/*
* Contant Declarations
*/
@ -35,8 +38,8 @@ export function entryLoading(collection, slug) {
type: ENTRY_REQUEST,
payload: {
collection: collection.get('name'),
slug: slug
}
slug,
},
};
}
@ -45,8 +48,8 @@ export function entryLoaded(collection, entry) {
type: ENTRY_SUCCESS,
payload: {
collection: collection.get('name'),
entry: entry
}
entry,
},
};
}
@ -54,8 +57,8 @@ export function entriesLoading(collection) {
return {
type: ENTRIES_REQUEST,
payload: {
collection: collection.get('name')
}
collection: collection.get('name'),
},
};
}
@ -64,9 +67,9 @@ export function entriesLoaded(collection, entries, pagination) {
type: ENTRIES_SUCCESS,
payload: {
collection: collection.get('name'),
entries: entries,
page: pagination
}
entries,
page: pagination,
},
};
}
@ -75,7 +78,7 @@ export function entriesFailed(collection, error) {
type: ENTRIES_FAILURE,
error: 'Failed to load entries',
payload: error.toString(),
meta: { collection: collection.get('name') }
meta: { collection: collection.get('name') },
};
}
@ -83,9 +86,9 @@ export function entryPersisting(collection, entry) {
return {
type: ENTRY_PERSIST_REQUEST,
payload: {
collection: collection,
entry: entry
}
collectionName: collection.get('name'),
entrySlug: entry.get('slug'),
},
};
}
@ -93,52 +96,56 @@ export function entryPersisted(collection, entry) {
return {
type: ENTRY_PERSIST_SUCCESS,
payload: {
collection: collection,
entry: entry
}
collectionName: collection.get('name'),
entrySlug: entry.get('slug'),
},
};
}
export function entryPersistFail(collection, entry, error) {
return {
type: ENTRIES_FAILURE,
type: ENTRY_PERSIST_FAILURE,
error: 'Failed to persist entry',
payload: error.toString()
payload: {
collectionName: collection.get('name'),
entrySlug: entry.get('slug'),
error: error.toString(),
},
};
}
export function emmptyDraftCreated(entry) {
return {
type: DRAFT_CREATE_EMPTY,
payload: entry
payload: entry,
};
}
export function searchingEntries(searchTerm) {
return {
type: SEARCH_ENTRIES_REQUEST,
payload: { searchTerm }
payload: { searchTerm },
};
}
export function SearchSuccess(searchTerm, entries, page) {
export function searchSuccess(searchTerm, entries, page) {
return {
type: SEARCH_ENTRIES_SUCCESS,
payload: {
searchTerm,
entries,
page
}
page,
},
};
}
export function SearchFailure(searchTerm, error) {
export function searchFailure(searchTerm, error) {
return {
type: SEARCH_ENTRIES_FAILURE,
payload: {
searchTerm,
error
}
error,
},
};
}
@ -148,20 +155,20 @@ export function SearchFailure(searchTerm, error) {
export function createDraftFromEntry(entry) {
return {
type: DRAFT_CREATE_FROM_ENTRY,
payload: entry
payload: entry,
};
}
export function discardDraft() {
return {
type: DRAFT_DISCARD
type: DRAFT_DISCARD,
};
}
export function changeDraft(entry) {
return {
type: DRAFT_CHANGE,
payload: entry
payload: entry,
};
}
@ -180,20 +187,22 @@ export function loadEntry(entry, collection, slug) {
} else {
getPromise = backend.lookupEntry(collection, slug);
}
return getPromise.then((loadedEntry) => dispatch(entryLoaded(collection, loadedEntry)));
return getPromise.then(loadedEntry => dispatch(entryLoaded(collection, loadedEntry)));
};
}
export function loadEntries(collection, page = 0) {
return (dispatch, getState) => {
if (collection.get('isFetching')) { return; }
if (collection.get('isFetching')) {
return;
}
const state = getState();
const integration = selectIntegration(state, collection.get('name'), 'listEntries');
const provider = integration ? getIntegrationProvider(state.integrations, integration) : currentBackend(state.config);
dispatch(entriesLoading(collection));
provider.listEntries(collection, page).then(
(response) => dispatch(entriesLoaded(collection, response.entries, response.pagination)),
(error) => dispatch(entriesFailed(collection, error))
response => dispatch(entriesLoaded(collection, response.entries, response.pagination)),
error => dispatch(entriesFailed(collection, error))
);
};
}
@ -207,18 +216,31 @@ export function createEmptyDraft(collection) {
};
}
export function persistEntry(collection, entry) {
export function persistEntry(collection, entryDraft) {
return (dispatch, getState) => {
const state = getState();
const backend = currentBackend(state.config);
const MediaProxies = entry.get('mediaFiles').map(path => getMedia(state, path));
const mediaProxies = entryDraft.get('mediaFiles').map(path => getMedia(state, path));
const entry = entryDraft.get('entry');
dispatch(entryPersisting(collection, entry));
backend.persistEntry(state.config, collection, entry, MediaProxies.toJS()).then(
() => {
backend
.persistEntry(state.config, collection, entryDraft, mediaProxies.toJS())
.then(() => {
dispatch(notifSend({
message: 'Entry saved',
kind: 'success',
dismissAfter: 4000,
}));
dispatch(entryPersisted(collection, entry));
},
(error) => dispatch(entryPersistFail(collection, entry, error))
);
})
.catch((error) => {
dispatch(notifSend({
message: 'Failed to persist entry',
kind: 'danger',
dismissAfter: 4000,
}));
dispatch(entryPersistFail(collection, entry, error));
});
};
}
@ -228,12 +250,16 @@ export function searchEntries(searchTerm, page = 0) {
let collections = state.collections.keySeq().toArray();
collections = collections.filter(collection => selectIntegration(state, collection, 'search'));
const integration = selectIntegration(state, collections[0], 'search');
if (!integration) console.warn('There isn\'t a search integration configured.');
const provider = integration ? getIntegrationProvider(state.integrations, integration) : currentBackend(state.config);
if (!integration) {
dispatch(searchFailure(searchTerm, 'Search integration is not configured.'));
}
const provider = integration ?
getIntegrationProvider(state.integrations, integration)
: currentBackend(state.config);
dispatch(searchingEntries(searchTerm));
provider.search(collections, searchTerm, page).then(
(response) => dispatch(SearchSuccess(searchTerm, response.entries, response.pagination)),
(error) => dispatch(SearchFailure(searchTerm, error))
response => dispatch(searchSuccess(searchTerm, response.entries, response.pagination)),
error => dispatch(searchFailure(searchTerm, error))
);
};
}

View File

@ -1,4 +1,5 @@
import React from 'react';
import React, { PropTypes } from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import pluralize from 'pluralize';
import { IndexLink } from 'react-router';
import { Menu, MenuItem } from 'react-toolbox';
@ -8,11 +9,20 @@ import styles from './AppHeader.css';
export default class AppHeader extends React.Component {
state = {
createMenuActive: false
static propTypes = {
collections: ImmutablePropTypes.orderedMap.isRequired,
commands: PropTypes.array.isRequired, // eslint-disable-line
defaultCommands: PropTypes.array.isRequired, // eslint-disable-line
runCommand: PropTypes.func.isRequired,
toggleNavDrawer: PropTypes.func.isRequired,
onCreateEntryClick: PropTypes.func.isRequired,
};
handleCreatePostClick = collectionName => {
state = {
createMenuActive: false,
};
handleCreatePostClick = (collectionName) => {
const { onCreateEntryClick } = this.props;
if (onCreateEntryClick) {
onCreateEntryClick(collectionName);
@ -21,13 +31,13 @@ export default class AppHeader extends React.Component {
handleCreateButtonClick = () => {
this.setState({
createMenuActive: true
createMenuActive: true,
});
};
handleCreateMenuHide = () => {
this.setState({
createMenuActive: false
createMenuActive: false,
});
};
@ -37,41 +47,40 @@ export default class AppHeader extends React.Component {
commands,
defaultCommands,
runCommand,
toggleNavDrawer
toggleNavDrawer,
} = this.props;
const { createMenuActive } = this.state;
return (
<AppBar
fixed
theme={styles}
leftIcon="menu"
rightIcon="create"
onLeftIconClick={toggleNavDrawer}
onRightIconClick={this.handleCreateButtonClick}
fixed
theme={styles}
leftIcon="menu"
rightIcon="create"
onLeftIconClick={toggleNavDrawer}
onRightIconClick={this.handleCreateButtonClick}
>
<IndexLink to="/">
Dashboard
</IndexLink>
<FindBar
commands={commands}
defaultCommands={defaultCommands}
runCommand={runCommand}
commands={commands}
defaultCommands={defaultCommands}
runCommand={runCommand}
/>
<Menu
active={createMenuActive}
position="topRight"
onHide={this.handleCreateMenuHide}
active={createMenuActive}
position="topRight"
onHide={this.handleCreateMenuHide}
>
{
collections.valueSeq().map(collection =>
<MenuItem
key={collection.get('name')}
value={collection.get('name')}
onClick={this.handleCreatePostClick.bind(this, collection.get('name'))}
caption={pluralize(collection.get('label'), 1)}
key={collection.get('name')}
value={collection.get('name')}
onClick={this.handleCreatePostClick.bind(this, collection.get('name'))} // eslint-disable-line
caption={pluralize(collection.get('label'), 1)}
/>
)
}

View File

@ -1,40 +0,0 @@
import React, { PropTypes } from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { resolveWidget } from './Widgets';
export default class ControlPane extends React.Component {
controlFor(field) {
const { entry, getMedia, onChange, onAddMedia, onRemoveMedia } = this.props;
const widget = resolveWidget(field.get('widget'));
const value = entry.getIn(['data', field.get('name')]);
if (!value) return null;
return <div className="cms-control">
<label>{field.get('label')}</label>
{React.createElement(widget.control, {
field: field,
value: value,
onChange: (value) => onChange(entry.setIn(['data', field.get('name')], value)),
onAddMedia: onAddMedia,
onRemoveMedia: onRemoveMedia,
getMedia: getMedia
})}
</div>;
}
render() {
const { collection } = this.props;
if (!collection) { return null; }
return <div>
{collection.get('fields').map((field) => <div key={field.get('name')} className="cms-widget">{this.controlFor(field)}</div>)}
</div>;
}
}
ControlPane.propTypes = {
collection: ImmutablePropTypes.map.isRequired,
entry: ImmutablePropTypes.map.isRequired,
getMedia: PropTypes.func.isRequired,
onAddMedia: PropTypes.func.isRequired,
onChange: PropTypes.func.isRequired,
onRemoveMedia: PropTypes.func.isRequired,
};

View File

@ -0,0 +1,53 @@
.control {
color: #7c8382;
position: relative;
padding: 20px 0;
& input,
& textarea,
& select {
font-family: monospace;
display: block;
width: 100%;
padding: 0;
margin: 0;
border: none;
outline: 0;
box-shadow: none;
background: 0 0;
font-size: 18px;
color: #7c8382;
}
}
.label {
color: #AAB0AF;
font-size: 12px;
margin-bottom: 18px;
}
.widget {
border-bottom: 1px solid #e8eae8;
position: relative;
&:after {
content: '';
position: absolute;
left: 42px;
bottom: -7px;
width: 12px;
height: 12px;
background-color: #f2f5f4;
-webkit-transform: rotate(45deg);
transform: rotate(45deg);
z-index: 1;
border-right: 1px solid #e8eae8;
border-bottom: 1px solid #e8eae8;
}
&:last-child {
border-bottom: none;
}
&:last-child:after {
display: none;
}
}

View File

@ -0,0 +1,62 @@
import React, { Component, PropTypes } from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { resolveWidget } from '../Widgets';
import styles from './ControlPane.css';
export default class ControlPane extends Component {
controlFor(field) {
const { entry, getMedia, onChange, onAddMedia, onRemoveMedia } = this.props;
const widget = resolveWidget(field.get('widget'));
const fieldName = field.get('name');
const value = entry.getIn(['data', fieldName]);
if (!value) return null;
return (
<div className={styles.control}>
<label className={styles.label} htmlFor={fieldName}>{field.get('label')}</label>
{
React.createElement(widget.control, {
field,
value,
onChange: val => onChange(entry.setIn(['data', fieldName], val)),
onAddMedia,
onRemoveMedia,
getMedia,
})
}
</div>
);
}
render() {
const { collection } = this.props;
if (!collection) {
return null;
}
return (
<div>
{
collection
.get('fields')
.map(field =>
<div
key={field.get('name')}
className={styles.widget}
>
{this.controlFor(field)}
</div>
)
}
</div>
);
}
}
ControlPane.propTypes = {
collection: ImmutablePropTypes.map.isRequired,
entry: ImmutablePropTypes.map.isRequired,
getMedia: PropTypes.func.isRequired,
onAddMedia: PropTypes.func.isRequired,
onChange: PropTypes.func.isRequired,
onRemoveMedia: PropTypes.func.isRequired,
};

View File

@ -1,26 +0,0 @@
.entryEditor {
display: flex;
flex-direction: column;
height: 100%;
}
.container {
display: flex;
height: 100%;
}
.footer {
background: #fff;
height: 45px;
border-top: 1px solid #e8eae8;
padding: 10px 20px;
z-index: 10;
}
.controlPane {
width: 50%;
max-height: 100%;
overflow: auto;
padding: 0 20px;
border-right: 1px solid #e8eae8;
}
.previewPane {
width: 50%;
}

View File

@ -1,65 +0,0 @@
import React, { PropTypes } from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ControlPane from './ControlPane';
import PreviewPane from './PreviewPane';
import styles from './EntryEditor.css';
export default class EntryEditor extends React.Component {
state = {};
componentDidMount() {
this.calculateHeight();
window.addEventListener('resize', this.handleResize, false);
}
componengWillUnmount() {
window.removeEventListener('resize', this.handleResize);
}
handleResize = () => {
this.calculateHeight();
};
calculateHeight() {
const height = window.innerHeight - 54;
console.log('setting height to %s', height);
this.setState({ height });
}
render() {
const { collection, entry, getMedia, onChange, onAddMedia, onRemoveMedia, onPersist } = this.props;
const { height } = this.state;
return <div className={styles.entryEditor} style={{ height }}>
<div className={styles.container}>
<div className={styles.controlPane}>
<ControlPane
collection={collection}
entry={entry}
getMedia={getMedia}
onChange={onChange}
onAddMedia={onAddMedia}
onRemoveMedia={onRemoveMedia}
/>
</div>
<div className={styles.previewPane}>
<PreviewPane collection={collection} entry={entry} getMedia={getMedia} />
</div>
</div>
<div className={styles.footer}>
<button onClick={onPersist}>Save</button>
</div>
</div>;
}
}
EntryEditor.propTypes = {
collection: ImmutablePropTypes.map.isRequired,
entry: ImmutablePropTypes.map.isRequired,
getMedia: PropTypes.func.isRequired,
onAddMedia: PropTypes.func.isRequired,
onChange: PropTypes.func.isRequired,
onPersist: PropTypes.func.isRequired,
onRemoveMedia: PropTypes.func.isRequired,
};

View File

@ -0,0 +1,36 @@
:root {
--defaultColorLight: #eee;
--backgroundColor: #fff;
}
.root {
position: absolute;
top: 64px;
bottom: 0;
display: flex;
flex-direction: column;
}
.container {
display: flex;
flex: 1;
}
.footer {
flex: 0;
padding: 10px 20px;
border-top: 1px solid var(--defaultColorLight);
background: var(--backgroundColor);
text-align: right;
}
.controlPane {
flex: 1;
overflow: auto;
padding: 0 20px;
border-right: 1px solid var(--defaultColorLight);
}
.previewPane {
flex: 1;
}

View File

@ -0,0 +1,65 @@
import React, { PropTypes } from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { ScrollSync, ScrollSyncPane } from '../ScrollSync';
import ControlPane from '../ControlPanel/ControlPane';
import PreviewPane from '../PreviewPane/PreviewPane';
import Toolbar from './EntryEditorToolbar';
import styles from './EntryEditor.css';
export default function EntryEditor(
{
collection,
entry,
getMedia,
onChange,
onAddMedia,
onRemoveMedia,
onPersist,
onCancelEdit,
}) {
return (
<div className={styles.root}>
<ScrollSync>
<div className={styles.container}>
<ScrollSyncPane>
<div className={styles.controlPane}>
<ControlPane
collection={collection}
entry={entry}
getMedia={getMedia}
onChange={onChange}
onAddMedia={onAddMedia}
onRemoveMedia={onRemoveMedia}
/>
</div>
</ScrollSyncPane>
<div className={styles.previewPane}>
<PreviewPane
collection={collection}
entry={entry}
getMedia={getMedia}
/>
</div>
</div>
</ScrollSync>
<div className={styles.footer}>
<Toolbar
isPersisting={entry.get('isPersisting')}
onPersist={onPersist}
onCancelEdit={onCancelEdit}
/>
</div>
</div>
);
}
EntryEditor.propTypes = {
collection: ImmutablePropTypes.map.isRequired,
entry: ImmutablePropTypes.map.isRequired,
getMedia: PropTypes.func.isRequired,
onAddMedia: PropTypes.func.isRequired,
onChange: PropTypes.func.isRequired,
onPersist: PropTypes.func.isRequired,
onRemoveMedia: PropTypes.func.isRequired,
onCancelEdit: PropTypes.func.isRequired,
};

View File

@ -0,0 +1,35 @@
import React, { PropTypes } from 'react';
import { Button } from 'react-toolbox/lib/button';
const EntryEditorToolbar = (
{
isPersisting,
onPersist,
onCancelEdit,
}) => {
const disabled = isPersisting;
return (
<div>
<Button
primary
raised
onClick={onPersist}
disabled={disabled}
>
{ isPersisting ? 'Saving...' : 'Save' }
</Button>
{' '}
<Button onClick={onCancelEdit}>
Cancel
</Button>
</div>
);
};
EntryEditorToolbar.propTypes = {
isPersisting: PropTypes.bool,
onPersist: PropTypes.func.isRequired,
onCancelEdit: PropTypes.func.isRequired,
};
export default EntryEditorToolbar;

View File

@ -0,0 +1,28 @@
import React from 'react';
import { shallow } from 'enzyme';
import EntryEditorToolbar from '../EntryEditorToolbar';
describe('EntryEditorToolbar', () => {
it('should have both buttons enabled initially', () => {
const component = shallow(
<EntryEditorToolbar
onPersist={() => {}}
onCancelEdit={() => {}}
/>
);
const tree = component.html();
expect(tree).toMatchSnapshot();
});
it('should disable and update label of Save button when persisting', () => {
const component = shallow(
<EntryEditorToolbar
isPersisting
onPersist={() => {}}
onCancelEdit={() => {}}
/>
);
const tree = component.html();
expect(tree).toMatchSnapshot();
});
});

View File

@ -0,0 +1,3 @@
exports[`EntryEditorToolbar should disable and update label of Save button when persisting 1`] = `"<div><button disabled=\"\" class=\"\" data-react-toolbox=\"button\">Saving...</button> <button class=\"\" data-react-toolbox=\"button\">Cancel</button></div>"`;
exports[`EntryEditorToolbar should have both buttons enabled initially 1`] = `"<div><button class=\"\" data-react-toolbox=\"button\">Save</button> <button class=\"\" data-react-toolbox=\"button\">Cancel</button></div>"`;

View File

@ -307,11 +307,11 @@ class FindBar extends Component {
}
return (
<div
className={this.state.highlightedIndex === index ? styles.highlightedCommand : styles.command}
key={command.token.trim().replace(/[^a-z0-9]+/gi, '-')}
onMouseDown={() => this.setIgnoreBlur(true)}
onMouseEnter={() => this.highlightCommandFromMouse(index)}
onClick={() => this.selectCommandFromMouse(command)}
className={this.state.highlightedIndex === index ? styles.highlightedCommand : styles.command}
key={command.token.trim().replace(/[^a-z0-9]+/gi, '-')}
onMouseDown={() => this.setIgnoreBlur(true)}
onMouseEnter={() => this.highlightCommandFromMouse(index)}
onClick={() => this.selectCommandFromMouse(command)}
>
{children}
</div>
@ -345,15 +345,15 @@ class FindBar extends Component {
<label className={styles.inputArea}>
{scope}
<input
className={styles.inputField}
ref={(c) => this._input = c}
onFocus={this.handleInputFocus}
onBlur={this.handleInputBlur}
onChange={this.handleChange}
onKeyDown={this.handleKeyDown}
onClick={this.handleInputClick}
placeholder={this.state.placeholder}
value={this.state.value}
className={styles.inputField}
ref={(c) => this._input = c}
onFocus={this.handleInputFocus}
onBlur={this.handleInputBlur}
onChange={this.handleChange}
onKeyDown={this.handleKeyDown}
onClick={this.handleInputClick}
placeholder={this.state.placeholder}
value={this.state.value}
/>
</label>
{menu}

View File

@ -0,0 +1,249 @@
/* eslint max-len:0 */
import React from 'react';
import { shallow } from 'enzyme';
import { padStart } from 'lodash';
import { Map } from 'immutable';
import MarkupIt from 'markup-it';
import markdownSyntax from 'markup-it/syntaxes/markdown';
import htmlSyntax from 'markup-it/syntaxes/html';
import reInline from 'markup-it/syntaxes/markdown/re/inline';
import MarkupItReactRenderer from '../';
describe('MarkitupReactRenderer', () => {
describe('basics', () => {
it('should re-render properly after a value and syntax update', () => {
const component = shallow(
<MarkupItReactRenderer
value="# Title"
syntax={markdownSyntax}
/>
);
const tree1 = component.html();
component.setProps({
value: '<h1>Title</h1>',
syntax: htmlSyntax,
});
const tree2 = component.html();
expect(tree1).toEqual(tree2);
});
it('should not update the parser if syntax didn\'t change', () => {
const component = shallow(
<MarkupItReactRenderer
value="# Title"
syntax={markdownSyntax}
/>
);
const syntax1 = component.instance().props.syntax;
component.setProps({
value: '## Title',
});
const syntax2 = component.instance().props.syntax;
expect(syntax1).toEqual(syntax2);
});
});
describe('Markdown rendering', () => {
describe('General', () => {
it('should render markdown', () => {
const value = `
# H1
Text with **bold** & _em_ elements
## H2
* ul item 1
* ul item 2
### H3
1. ol item 1
1. ol item 2
1. ol item 3
#### H4
[link title](http://google.com)
##### H5
![alt text](https://pbs.twimg.com/profile_images/678903331176214528/TQTdqGwD.jpg)
###### H6
`;
const component = shallow(
<MarkupItReactRenderer
value={value}
syntax={markdownSyntax}
/>
);
expect(component.html()).toMatchSnapshot();
});
});
describe('Headings', () => {
for (const heading of [...Array(6).keys()]) {
it(`should render Heading ${ heading + 1 }`, () => {
const value = padStart(' Title', heading + 7, '#');
const component = shallow(
<MarkupItReactRenderer
value={value}
syntax={markdownSyntax}
/>
);
expect(component.html()).toMatchSnapshot();
});
}
});
describe('Lists', () => {
it('should render lists', () => {
const value = `
1. ol item 1
1. ol item 2
* Sublist 1
* Sublist 2
* Sublist 3
1. Sub-Sublist 1
1. Sub-Sublist 2
1. Sub-Sublist 3
1. ol item 3
`;
const component = shallow(
<MarkupItReactRenderer
value={value}
syntax={markdownSyntax}
/>
);
expect(component.html()).toMatchSnapshot();
});
});
describe('Links', () => {
it('should render links', () => {
const value = `
I get 10 times more traffic from [Google] [1] than from [Yahoo] [2] or [MSN] [3].
[1]: http://google.com/ "Google"
[2]: http://search.yahoo.com/ "Yahoo Search"
[3]: http://search.msn.com/ "MSN Search"
`;
const component = shallow(
<MarkupItReactRenderer
value={value}
syntax={markdownSyntax}
/>
);
expect(component.html()).toMatchSnapshot();
});
});
describe('Code', () => {
it('should render code', () => {
const value = 'Use the `printf()` function.';
const component = shallow(
<MarkupItReactRenderer
value={value}
syntax={markdownSyntax}
/>
);
expect(component.html()).toMatchSnapshot();
});
it('should render code 2', () => {
const value = '``There is a literal backtick (`) here.``';
const component = shallow(
<MarkupItReactRenderer
value={value}
syntax={markdownSyntax}
/>
);
expect(component.html()).toMatchSnapshot();
});
});
describe('HTML', () => {
it('should render HTML as is when using Markdown', () => {
const value = `
# Title
<form action="test">
<label for="input">
<input type="checkbox" checked="checked" id="input"/> My label
</label>
<dl class="test-class another-class" style="width: 100%">
<dt data-attr="test">Test HTML content</dt>
<dt>Testing HTML in Markdown</dt>
</dl>
</form>
<h1 style="display: block; border: 10px solid #f00; width: 100%">Test</h1>
`;
const component = shallow(
<MarkupItReactRenderer
value={value}
syntax={markdownSyntax}
/>
);
expect(component.html()).toMatchSnapshot();
});
});
});
describe('custom elements', () => {
it('should extend default renderers with custom ones', () => {
const myRule = MarkupIt.Rule('mediaproxy') // eslint-disable-line
.regExp(reInline.link, (state, match) => {
if (match[0].charAt(0) !== '!') {
return null;
}
return {
data: Map({
alt: match[1],
src: match[2],
title: match[3],
}).filter(Boolean),
};
});
const myCustomSchema = {
mediaproxy: ({ token }) => { //eslint-disable-line
const src = token.getIn(['data', 'src']);
const alt = token.getIn(['data', 'alt']);
return <img src={src} alt={alt} />;
},
};
const myMarkdownSyntax = markdownSyntax.addInlineRules(myRule);
const value = `
## Title
![mediaproxy test](http://url.to.image)
`;
const component = shallow(
<MarkupItReactRenderer
value={value}
syntax={myMarkdownSyntax}
schema={myCustomSchema}
/>
);
expect(component.html()).toMatchSnapshot();
});
});
describe('HTML rendering', () => {
it('should render HTML', () => {
const value = '<p>Paragraph with <em>inline</em> element</p>';
const component = shallow(
<MarkupItReactRenderer
value={value}
syntax={htmlSyntax}
/>
);
expect(component.html()).toMatchSnapshot();
});
});
});

View File

@ -0,0 +1,40 @@
exports[`MarkitupReactRenderer HTML rendering should render HTML 1`] = `"<article><p>Paragraph with <em>inline</em> element</p></article>"`;
exports[`MarkitupReactRenderer Markdown rendering Code should render code 1`] = `"<article><p>Use the <code>printf()</code> function.</p></article>"`;
exports[`MarkitupReactRenderer Markdown rendering Code should render code 2 1`] = `"<article><p><code>There is a literal backtick (\`) here.</code></p></article>"`;
exports[`MarkitupReactRenderer Markdown rendering General should render markdown 1`] = `"<article><h1>H1</h1><p>Text with <strong>bold</strong> &amp; <em>em</em> elements</p><h2>H2</h2><ul><li>ul item 1</li><li>ul item 2</li></ul><h3>H3</h3><ol><li>ol item 1</li><li>ol item 2</li><li>ol item 3</li></ol><h4>H4</h4><p><a href=\"http://google.com\">link title</a></p><h5>H5</h5><p><img alt=\"alt text\" src=\"https://pbs.twimg.com/profile_images/678903331176214528/TQTdqGwD.jpg\"/></p><h6>H6</h6></article>"`;
exports[`MarkitupReactRenderer Markdown rendering HTML should render HTML as is when using Markdown 1`] = `
"<article><h1>Title</h1><div><form action=\"test\">
<label for=\"input\">
<input type=\"checkbox\" checked=\"checked\" id=\"input\"/> My label
</label>
<dl class=\"test-class another-class\" style=\"width: 100%\">
<dt data-attr=\"test\">Test HTML content</dt>
<dt>Testing HTML in Markdown</dt>
</dl>
</form>
</div><div><h1 style=\"display: block; border: 10px solid #f00; width: 100%\">Test</h1>
</div></article>"
`;
exports[`MarkitupReactRenderer Markdown rendering Headings should render Heading 1 1`] = `"<article><h1>Title</h1></article>"`;
exports[`MarkitupReactRenderer Markdown rendering Headings should render Heading 2 1`] = `"<article><h2>Title</h2></article>"`;
exports[`MarkitupReactRenderer Markdown rendering Headings should render Heading 3 1`] = `"<article><h3>Title</h3></article>"`;
exports[`MarkitupReactRenderer Markdown rendering Headings should render Heading 4 1`] = `"<article><h4>Title</h4></article>"`;
exports[`MarkitupReactRenderer Markdown rendering Headings should render Heading 5 1`] = `"<article><h5>Title</h5></article>"`;
exports[`MarkitupReactRenderer Markdown rendering Headings should render Heading 6 1`] = `"<article><h6>Title</h6></article>"`;
exports[`MarkitupReactRenderer Markdown rendering Links should render links 1`] = `"<article><p>I get 10 times more traffic from <a href=\"http://google.com/\" title=\"Google\">Google</a> than from <a href=\"http://search.yahoo.com/\" title=\"Yahoo Search\">Yahoo</a> or <a href=\"http://search.msn.com/\" title=\"MSN Search\">MSN</a>.</p></article>"`;
exports[`MarkitupReactRenderer Markdown rendering Lists should render lists 1`] = `"<article><ol><li>ol item 1</li><li>ol item 2<ul><li>Sublist 1</li><li>Sublist 2</li><li>Sublist 3<ol><li>Sub-Sublist 1</li><li>Sub-Sublist 2</li><li>Sub-Sublist 3</li></ol></li></ul></li><li>ol item 3</li></ol></article>"`;
exports[`MarkitupReactRenderer custom elements should extend default renderers with custom ones 1`] = `"<article><h2>Title</h2><p><img src=\"http://url.to.image\" alt=\"mediaproxy test\"/></p></article>"`;

View File

@ -0,0 +1,106 @@
import React, { PropTypes } from 'react';
import MarkupIt, { Syntax, BLOCKS, STYLES, ENTITIES } from 'markup-it';
import { omit } from 'lodash';
const defaultSchema = {
[BLOCKS.DOCUMENT]: 'article',
[BLOCKS.TEXT]: null,
[BLOCKS.CODE]: 'code',
[BLOCKS.BLOCKQUOTE]: 'blockquote',
[BLOCKS.PARAGRAPH]: 'p',
[BLOCKS.FOOTNOTE]: 'footnote',
[BLOCKS.HTML]: ({ token }) => {
return <div dangerouslySetInnerHTML={{ __html: token.get('raw') }}/>;
},
[BLOCKS.HR]: 'hr',
[BLOCKS.HEADING_1]: 'h1',
[BLOCKS.HEADING_2]: 'h2',
[BLOCKS.HEADING_3]: 'h3',
[BLOCKS.HEADING_4]: 'h4',
[BLOCKS.HEADING_5]: 'h5',
[BLOCKS.HEADING_6]: 'h6',
[BLOCKS.TABLE]: 'table',
[BLOCKS.TABLE_ROW]: 'tr',
[BLOCKS.TABLE_CELL]: 'td',
[BLOCKS.OL_LIST]: 'ol',
[BLOCKS.UL_LIST]: 'ul',
[BLOCKS.LIST_ITEM]: 'li',
[STYLES.TEXT]: null,
[STYLES.BOLD]: 'strong',
[STYLES.ITALIC]: 'em',
[STYLES.CODE]: 'code',
[STYLES.STRIKETHROUGH]: 'del',
[ENTITIES.LINK]: 'a',
[ENTITIES.IMAGE]: 'img',
[ENTITIES.FOOTNOTE_REF]: 'sup',
[ENTITIES.HARD_BREAK]: 'br'
};
const notAllowedAttributes = ['loose'];
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);
}
componentWillReceiveProps(nextProps) {
if (nextProps.syntax != this.props.syntax) {
this.parser = new MarkupIt(nextProps.syntax);
}
}
render() {
const { value, schema } = this.props;
const content = this.parser.toContent(value);
return renderToken({ ...defaultSchema, ...schema }, content.get('token'));
}
}
MarkupItReactRenderer.propTypes = {
value: PropTypes.string,
syntax: PropTypes.instanceOf(Syntax).isRequired,
schema: PropTypes.objectOf(PropTypes.oneOfType([
PropTypes.string,
PropTypes.func
]))
};

View File

@ -1,84 +0,0 @@
import React, { PropTypes } from 'react';
import { render } from 'react-dom';
import ImmutablePropTypes from 'react-immutable-proptypes';
import registry from '../lib/registry';
import { resolveWidget } from './Widgets';
import styles from './PreviewPane.css';
class Preview extends React.Component {
static propTypes = {
collection: ImmutablePropTypes.map.isRequired,
entry: ImmutablePropTypes.map.isRequired,
getMedia: PropTypes.func.isRequired,
};
previewFor(field) {
const { entry, getMedia } = this.props;
const widget = resolveWidget(field.get('widget'));
return React.createElement(widget.preview, {
field: field,
value: entry.getIn(['data', field.get('name')]),
getMedia: getMedia,
});
}
render() {
const { collection } = this.props;
if (!collection) { return null; }
return <div>
{collection.get('fields').map((field) => <div key={field.get('name')}>{this.previewFor(field)}</div>)}
</div>;
}
}
export default class PreviewPane extends React.Component {
componentDidUpdate() {
this.renderPreview();
}
widgetFor = name => {
const { collection, entry, getMedia } = this.props;
const field = collection.get('fields').find((field) => field.get('name') === name);
const widget = resolveWidget(field.get('widget'));
return React.createElement(widget.preview, {
field: field,
value: entry.getIn(['data', field.get('name')]),
getMedia: getMedia,
});
};
renderPreview() {
const props = Object.assign({}, this.props, { widgetFor: this.widgetFor });
const component = registry.getPreviewTemplate(props.collection.get('name')) || Preview;
render(React.createElement(component, props), this.previewEl);
}
handleIframeRef = ref => {
if (ref) {
registry.getPreviewStyles().forEach((style) => {
const linkEl = document.createElement('link');
linkEl.setAttribute('rel', 'stylesheet');
linkEl.setAttribute('href', style);
ref.contentDocument.head.appendChild(linkEl);
});
this.previewEl = document.createElement('div');
ref.contentDocument.body.appendChild(this.previewEl);
this.renderPreview();
}
};
render() {
const { collection } = this.props;
if (!collection) { return null; }
return <iframe className={styles.frame} ref={this.handleIframeRef}></iframe>;
}
}
PreviewPane.propTypes = {
collection: ImmutablePropTypes.map.isRequired,
entry: ImmutablePropTypes.map.isRequired,
getMedia: PropTypes.func.isRequired,
};

View File

@ -0,0 +1,21 @@
import React, { PropTypes } from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
export default function Preview({ collection, widgetFor }) {
if (!collection) {
return null;
}
return (
<div>
{collection.get('fields').map(field => widgetFor(field.get('name')))}
</div>
);
}
Preview.propTypes = {
collection: ImmutablePropTypes.map.isRequired,
entry: ImmutablePropTypes.map.isRequired,
getMedia: PropTypes.func.isRequired,
widgetFor: PropTypes.func.isRequired,
};

View File

@ -1,6 +1,6 @@
.frame {
width: 100%;
height: 100vh;
height: 100%;
border: none;
background: #fff;
}

View File

@ -0,0 +1,75 @@
import React, { PropTypes } from 'react';
import ReactDOM from 'react-dom';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { ScrollSyncPane } from '../ScrollSync';
import registry from '../../lib/registry';
import { resolveWidget } from '../Widgets';
import Preview from './Preview';
import styles from './PreviewPane.css';
export default class PreviewPane extends React.Component {
componentDidUpdate() {
this.renderPreview();
}
widgetFor = name => {
const { collection, entry, getMedia } = this.props;
const field = collection.get('fields').find((field) => field.get('name') === name);
const widget = resolveWidget(field.get('widget'));
return React.createElement(widget.preview, {
key: field.get('name'),
value: entry.getIn(['data', field.get('name')]),
field,
getMedia,
});
};
renderPreview() {
const component = registry.getPreviewTemplate(this.props.collection.get('name')) || Preview;
const previewProps = {
...this.props,
widgetFor: this.widgetFor
};
// We need to use this API in order to pass context to the iframe
ReactDOM.unstable_renderSubtreeIntoContainer(
this,
<ScrollSyncPane attachTo={this.iframeBody}>
{React.createElement(component, previewProps)}
</ScrollSyncPane>
, this.previewEl);
}
handleIframeRef = ref => {
if (ref) {
registry.getPreviewStyles().forEach((style) => {
const linkEl = document.createElement('link');
linkEl.setAttribute('rel', 'stylesheet');
linkEl.setAttribute('href', style);
ref.contentDocument.head.appendChild(linkEl);
});
this.previewEl = document.createElement('div');
this.iframeBody = ref.contentDocument.body;
this.iframeBody.appendChild(this.previewEl);
this.renderPreview();
}
};
render() {
const { collection } = this.props;
if (!collection) {
return null;
}
return <iframe className={styles.frame} ref={this.handleIframeRef}></iframe>;
}
}
PreviewPane.propTypes = {
collection: ImmutablePropTypes.map.isRequired,
entry: ImmutablePropTypes.map.isRequired,
getMedia: PropTypes.func.isRequired,
scrollTop: PropTypes.number,
scrollHeight: PropTypes.number,
offsetHeight: PropTypes.number,
};

View File

@ -0,0 +1,81 @@
import React, { Component, PropTypes } from 'react';
import { without } from 'lodash';
export default class ScrollSync extends Component {
static propTypes = {
children: PropTypes.element.isRequired,
};
static childContextTypes = {
registerPane: PropTypes.func,
unregisterPane: PropTypes.func,
};
panes = [];
getChildContext() {
return {
registerPane: this.registerPane,
unregisterPane: this.unregisterPane,
};
}
registerPane = node => {
if (!this.findPane(node)) {
this.addEvents(node);
this.panes.push(node);
}
};
unregisterPane = node => {
if (this.findPane(node)) {
this.removeEvents(node);
this.panes = without(this.panes, node);
}
};
addEvents = node => {
node.onscroll = this.handlePaneScroll.bind(this, node);
// node.addEventListener('scroll', this.handlePaneScroll, false)
};
removeEvents = node => {
node.onscroll = null;
// node.removeEventListener('scroll', this.handlePaneScroll, false)
};
findPane = node => {
return this.panes.find(p => p === node);
};
handlePaneScroll = node => {
// const node = evt.target
window.requestAnimationFrame(() => {
this.syncScrollPositions(node);
});
};
syncScrollPositions = scrolledPane => {
const { scrollTop, scrollHeight, clientHeight } = scrolledPane;
this.panes.forEach(pane => {
/* For all panes beside the currently scrolling one */
if (scrolledPane !== pane) {
/* Remove event listeners from the node that we'll manipulate */
this.removeEvents(pane);
/* Calculate the actual pane height */
const paneHeight = pane.scrollHeight - clientHeight;
/* Adjust the scrollTop position of it accordingly */
pane.scrollTop = paneHeight * scrollTop / (scrollHeight - clientHeight);
/* Re-attach event listeners after we're done scrolling */
window.requestAnimationFrame(() => {
this.addEvents(pane);
});
}
});
};
render() {
return React.Children.only(this.props.children);
}
}

View File

@ -0,0 +1,28 @@
import { Component, PropTypes } from 'react';
import ReactDOM from 'react-dom';
export default class ScrollSyncPane extends Component {
static propTypes = {
children: PropTypes.node.isRequired,
attachTo: PropTypes.any
};
static contextTypes = {
registerPane: PropTypes.func.isRequired,
unregisterPane: PropTypes.func.isRequired,
};
componentDidMount() {
this.node = this.props.attachTo || ReactDOM.findDOMNode(this);
this.context.registerPane(this.node);
}
componentWillUnmount() {
this.context.unregisterPane(this.node);
}
render() {
return this.props.children;
}
}

View File

@ -0,0 +1,2 @@
export { default as ScrollSync } from './ScrollSync';
export { default as ScrollSyncPane } from './ScrollSyncPane';

View File

@ -36,9 +36,9 @@ export default class Loader extends React.Component {
this.setAnimation();
return <div className={styles.text}>
<ReactCSSTransitionGroup
transitionName={styles}
transitionEnterTimeout={500}
transitionLeaveTimeout={500}
transitionName={styles}
transitionEnterTimeout={500}
transitionLeaveTimeout={500}
>
<div key={currentItem} className={styles.animateItem}>{children[currentItem]}</div>
</ReactCSSTransitionGroup>

View File

@ -2,10 +2,13 @@
--defaultColor: #333;
--defaultColorLight: #eee;
--backgroundColor: #fff;
--shadowColor: rgba(0, 0, 0, 0.117647);
--shadowColor: rgba(0, 0, 0, .25);
--infoColor: #69c;
--successColor: #1c7;
--warningColor: #fa0;
--errorColor: #f52;
--borderRadius: 2px;
--topmostZindex: 99999;
}
.base {
@ -13,14 +16,14 @@
}
.container {
color: var(--defaultColor);
background-color: var(--backgroundColor);
color: var(--defaultColor);
}
.rounded {
border-radius: 2px;
border-radius: var(--borderRadius);
}
.depth {
box-shadow: var(--shadowColor) 0px 1px 6px, var(--shadowColor) 0px 1px 4px;
box-shadow: var(--shadowColor) 0 1px 6px;
}

View File

@ -1,40 +1,41 @@
@import "../theme.css";
@import '../theme.css';
.toast {
composes: base container rounded depth;
position: absolute;
top: 10px;
right: 10px;
z-index: 100;
width: 350px;
padding: 20px 10px;
font-size: 0.9rem;
text-align: center;
color: var(--defaultColorLight);
overflow: hidden;
opacity: 1;
transition: opacity .3s ease-in;
:root {
--iconSize: 30px;
}
.hidden {
opacity: 0;
.root {
composes: base container rounded depth from '../theme.css';
overflow: hidden;
margin: 10px;
padding: 10px 10px 15px;
color: var(--defaultColorLight);
}
.icon {
position: absolute;
top: calc(50% - 15px);
left: 15px;
font-size: 30px;
position: relative;
top: .15em;
margin-right: .25em;
font-size: var(--iconSize);
line-height: var(--iconSize);
}
.info {
composes: root;
background-color: var(--infoColor);
}
.success {
composes: root;
background-color: var(--successColor);
}
.warning {
composes: root;
background-color: var(--warningColor);
}
.error {
.danger {
composes: root;
background-color: var(--errorColor);
}

View File

@ -2,69 +2,23 @@ import React, { PropTypes } from 'react';
import { Icon } from '../index';
import styles from './Toast.css';
export default class Toast extends React.Component {
const icons = {
info: 'info',
success: 'check',
warning: 'attention',
danger: 'alert',
};
state = {
shown: false
};
componentWillMount() {
if (this.props.show) {
this.autoHideTimeout();
this.setState({ shown: true });
}
}
componentWillReceiveProps(nextProps) {
if (nextProps !== this.props) {
if (nextProps.show) this.autoHideTimeout();
this.setState({ shown: nextProps.show });
}
}
componentWillUnmount() {
if (this.timeOut) {
clearTimeout(this.timeOut);
}
}
autoHideTimeout = () => {
clearTimeout(this.timeOut);
this.timeOut = setTimeout(() => {
this.setState({ shown: false });
}, 4000);
};
render() {
const { style, type, className, children } = this.props;
const icons = {
success: 'check',
warning: 'attention',
error: 'alert'
};
const classes = [styles.toast];
if (className) classes.push(className);
let icon = '';
if (type) {
classes.push(styles[type]);
icon = <Icon type={icons[type]} className={styles.icon} />;
}
if (!this.state.shown) {
classes.push(styles.hidden);
}
return (
<div className={classes.join(' ')} style={style}>{icon}{children}</div>
);
}
export default function Toast({ kind, message }) {
return (
<div className={styles[kind]}>
<Icon type={icons[kind]} className={styles.icon} />
{message}
</div>
);
}
Toast.propTypes = {
style: PropTypes.object,
type: PropTypes.oneOf(['success', 'warning', 'error']).isRequired,
className: PropTypes.string,
show: PropTypes.bool,
children: PropTypes.node
kind: PropTypes.oneOf(['info', 'success', 'warning', 'danger']).isRequired,
message: PropTypes.string,
};

View File

@ -63,19 +63,19 @@ export default class ImageControl extends React.Component {
const imageName = this.renderImageName();
return (
<div
onDragEnter={this.handleDragEnter}
onDragOver={this.handleDragOver}
onDrop={this.handleChange}
onDragEnter={this.handleDragEnter}
onDragOver={this.handleDragOver}
onDrop={this.handleChange}
>
<span style={styles.imageUpload} onClick={this.handleClick}>
{imageName ? imageName : 'Tip: Click here to upload an image from your file browser, or drag an image directly into this box from your desktop'}
</span>
<input
type="file"
accept="image/*"
onChange={this.handleChange}
style={styles.input}
ref={this.handleFileInputRef}
type="file"
accept="image/*"
onChange={this.handleChange}
style={styles.input}
ref={this.handleFileInputRef}
/>
</div>
);

View File

@ -16,10 +16,6 @@ class MarkdownControl extends React.Component {
value: PropTypes.node,
};
static contextTypes = {
plugins: PropTypes.object,
};
componentWillMount() {
this.useRawEditor();
processEditorPlugins(registry.getEditorComponents());
@ -33,18 +29,18 @@ class MarkdownControl extends React.Component {
this.props.switchVisualMode(false);
};
renderEditor() {
render() {
const { editor, onChange, onAddMedia, getMedia, value } = this.props;
if (editor.get('useVisualMode')) {
return (
<div className='cms-editor-visual'>
{null && <button onClick={this.useRawEditor}>Switch to Raw Editor</button>}
<VisualEditor
onChange={onChange}
onAddMedia={onAddMedia}
getMedia={getMedia}
registeredComponents={editor.get('registeredComponents')}
value={value}
onChange={onChange}
onAddMedia={onAddMedia}
getMedia={getMedia}
registeredComponents={editor.get('registeredComponents')}
value={value}
/>
</div>
);
@ -53,24 +49,15 @@ class MarkdownControl extends React.Component {
<div className='cms-editor-raw'>
{null && <button onClick={this.useVisualEditor}>Switch to Visual Editor</button>}
<RawEditor
onChange={onChange}
onAddMedia={onAddMedia}
getMedia={getMedia}
value={value}
onChange={onChange}
onAddMedia={onAddMedia}
getMedia={getMedia}
value={value}
/>
</div>
);
}
}
render() {
return (
<div>
{this.renderEditor()}
</div>
);
}
}
export default connect(

View File

@ -0,0 +1,13 @@
.root {
font-family: monospace;
display: block;
width: 100%;
padding: 0;
margin: 0;
border: none;
outline: 0;
box-shadow: none;
background: 0 0;
font-size: 18px;
color: #7c8382;
}

View File

@ -1,7 +1,10 @@
import React, { PropTypes } from 'react';
import { Editor, Plain, Mark } from 'slate';
import Prism from 'prismjs';
import PluginDropImages from 'slate-drop-or-paste-images';
import MediaProxy from '../../../../valueObjects/MediaProxy';
import marks from './prismMarkdown';
import styles from './index.css';
Prism.languages.markdown = Prism.languages.extend('markup', {});
Prism.languages.insertBefore('markdown', 'prolog', marks);
@ -41,7 +44,6 @@ function renderDecorations(text, block) {
return characters.asImmutable();
}
const SCHEMA = {
rules: [
{
@ -70,7 +72,15 @@ const SCHEMA = {
}
};
class RawEditor extends React.Component {
export default class RawEditor extends React.Component {
static propTypes = {
onAddMedia: PropTypes.func.isRequired,
getMedia: PropTypes.func.isRequired,
onChange: PropTypes.func.isRequired,
value: PropTypes.string,
};
constructor(props) {
super(props);
const content = props.value ? Plain.deserialize(props.value) : Plain.deserialize('');
@ -78,12 +88,24 @@ class RawEditor extends React.Component {
this.state = {
state: content
};
this.plugins = [
PluginDropImages({
applyTransform: (transform, file) => {
const mediaProxy = new MediaProxy(file.name, file);
const state = Plain.deserialize(`\n\n![${file.name}](${mediaProxy.public_path})\n\n`);
props.onAddMedia(mediaProxy);
return transform
.insertFragment(state.get('document'));
}
})
];
}
/**
* Slate keeps track of selections, scroll position etc.
* So, onChange gets dispatched on every interaction (click, arrows, everything...)
* It also have an onDocumentChange, that get's dispached only when the actual
* It also have an onDocumentChange, that get's dispatched only when the actual
* content changes
*/
handleChange = state => {
@ -98,20 +120,14 @@ class RawEditor extends React.Component {
render() {
return (
<Editor
placeholder={'Enter some rich text...'}
state={this.state.state}
schema={SCHEMA}
onChange={this.handleChange}
onDocumentChange={this.handleDocumentChange}
renderDecorations={this.renderDecorations}
className={styles.root}
placeholder={'Enter some rich text...'}
state={this.state.state}
schema={SCHEMA}
onChange={this.handleChange}
onDocumentChange={this.handleDocumentChange}
plugins={this.plugins}
/>
);
}
}
export default RawEditor;
RawEditor.propTypes = {
onChange: PropTypes.func.isRequired,
value: PropTypes.node,
};

View File

@ -1,25 +1,21 @@
import React, { Component, PropTypes } from 'react';
import Portal from 'react-portal';
import withPortalAtCursorPosition from './withPortalAtCursorPosition';
import { Icon } from '../../../UI';
import MediaProxy from '../../../../valueObjects/MediaProxy';
import styles from './BlockTypesMenu.css';
export default class BlockTypesMenu extends Component {
constructor(props) {
super(props);
class BlockTypesMenu extends Component {
this.state = {
expanded: false,
menu: null
};
}
static propTypes = {
plugins: PropTypes.array.isRequired,
onClickBlock: PropTypes.func.isRequired,
onClickPlugin: PropTypes.func.isRequired,
onClickImage: PropTypes.func.isRequired
};
/**
* On update, update the menu.
*/
componentDidMount() {
this.updateMenuPosition();
}
state = {
expanded: false
};
componentWillUpdate() {
if (this.state.expanded) {
@ -27,21 +23,6 @@ export default class BlockTypesMenu extends Component {
}
}
componentDidUpdate() {
this.updateMenuPosition();
}
updateMenuPosition = () => {
const { menu } = this.state;
const { position } = this.props;
if (!menu) return;
menu.style.opacity = 1;
menu.style.top = `${position.top}px`;
menu.style.left = `${position.left - menu.offsetWidth * 2}px`;
};
toggleMenu = () => {
this.setState({ expanded: !this.state.expanded });
};
@ -53,7 +34,7 @@ export default class BlockTypesMenu extends Component {
handlePluginClick = (e, plugin) => {
const data = {};
plugin.fields.forEach(field => {
data[field.name] = window.prompt(field.label);
data[field.name] = window.prompt(field.label); // eslint-disable-line
});
this.props.onClickPlugin(plugin.id, data);
};
@ -87,14 +68,14 @@ export default class BlockTypesMenu extends Component {
renderBlockTypeButton = (type, icon) => {
const onClick = e => this.handleBlockTypeClick(e, type);
return (
<Icon key={type} type={icon} onClick={onClick} className={styles.icon} />
<Icon key={type} type={icon} onClick={onClick} className={styles.icon}/>
);
};
renderPluginButton = plugin => {
const onClick = e => this.handlePluginClick(e, plugin);
return (
<Icon key={plugin.id} type={plugin.icon} onClick={onClick} className={styles.icon} />
<Icon key={plugin.id} type={plugin.icon} onClick={onClick} className={styles.icon}/>
);
};
@ -105,13 +86,15 @@ export default class BlockTypesMenu extends Component {
<div className={styles.menu}>
{this.renderBlockTypeButton('hr', 'dot-3')}
{plugins.map(plugin => this.renderPluginButton(plugin))}
<Icon type="picture" onClick={this.handleFileUploadClick} className={styles.icon} />
<Icon type="picture" onClick={this.handleFileUploadClick} className={styles.icon}/>
<input
type="file"
accept="image/*"
onChange={this.handleFileUploadChange}
className={styles.input}
ref={(el) => this._fileInput = el}
type="file"
accept="image/*"
onChange={this.handleFileUploadChange}
className={styles.input}
ref={el => {
this._fileInput = el;
}}
/>
</div>
);
@ -120,34 +103,14 @@ export default class BlockTypesMenu extends Component {
}
}
/**
* When the portal opens, cache the menu element.
*/
handleOpen = portal => {
this.setState({ menu: portal.firstChild });
};
render() {
const { isOpen } = this.props;
return (
<Portal isOpened={isOpen} onOpen={this.handleOpen}>
<div className={styles.root}>
<Icon type="plus-squared" className={styles.button} onClick={this.toggleMenu} />
{this.renderMenu()}
</div>
</Portal>
<div className={styles.root}>
<Icon type="plus-squared" className={styles.button} onClick={this.toggleMenu}/>
{this.renderMenu()}
</div>
);
}
}
BlockTypesMenu.propTypes = {
isOpen: PropTypes.bool.isRequired,
plugins: PropTypes.array.isRequired,
position: PropTypes.shape({
top: PropTypes.number.isRequired,
left: PropTypes.number.isRequired
}),
onClickBlock: PropTypes.func.isRequired,
onClickPlugin: PropTypes.func.isRequired,
onClickImage: PropTypes.func.isRequired
};
export default withPortalAtCursorPosition(BlockTypesMenu);

View File

@ -1,36 +1,17 @@
import React, { Component, PropTypes } from 'react';
import Portal from 'react-portal';
import withPortalAtCursorPosition from './withPortalAtCursorPosition';
import { Icon } from '../../../UI';
import styles from './StylesMenu.css';
export default class StylesMenu extends Component {
constructor(props) {
super(props);
class StylesMenu extends Component {
this.state = {
menu: null
};
}
/**
* On update, update the menu.
*/
componentDidMount() {
this.updateMenuPosition();
}
componentDidUpdate() {
this.updateMenuPosition();
}
updateMenuPosition = () => {
const { menu } = this.state;
const { position } = this.props;
if (!menu) return;
menu.style.opacity = 1;
menu.style.top = `${position.top - menu.offsetHeight}px`;
menu.style.left = `${position.left - menu.offsetWidth / 2 + position.width / 2}px`;
static propTypes = {
marks: PropTypes.object.isRequired,
blocks: PropTypes.object.isRequired,
inlines: PropTypes.object.isRequired,
onClickBlock: PropTypes.func.isRequired,
onClickMark: PropTypes.func.isRequired,
onClickInline: PropTypes.func.isRequired
};
/**
@ -46,10 +27,10 @@ export default class StylesMenu extends Component {
return blocks.some(node => node.type == type);
};
hasLinks(type) {
hasLinks = type => {
const { inlines } = this.props;
return inlines.some(inline => inline.type == 'link');
}
};
handleMarkClick = (e, type) => {
e.preventDefault();
@ -99,42 +80,20 @@ export default class StylesMenu extends Component {
);
};
/**
* When the portal opens, cache the menu element.
*/
handleOpen = portal => {
this.setState({ menu: portal.firstChild });
};
render() {
const { isOpen } = this.props;
return (
<Portal isOpened={isOpen} onOpen={this.handleOpen}>
<div className={`${styles.menu} ${styles.hoverMenu}`}>
{this.renderMarkButton('BOLD', 'bold')}
{this.renderMarkButton('ITALIC', 'italic')}
{this.renderMarkButton('CODE', 'code')}
{this.renderLinkButton()}
{this.renderBlockButton('header_one', 'h1')}
{this.renderBlockButton('header_two', 'h2')}
{this.renderBlockButton('blockquote', 'quote-left')}
{this.renderBlockButton('unordered_list', 'list-bullet', 'list_item')}
</div>
</Portal>
<div className={`${styles.menu} ${styles.hoverMenu}`}>
{this.renderMarkButton('BOLD', 'bold')}
{this.renderMarkButton('ITALIC', 'italic')}
{this.renderMarkButton('CODE', 'code')}
{this.renderLinkButton()}
{this.renderBlockButton('header_one', 'h1')}
{this.renderBlockButton('header_two', 'h2')}
{this.renderBlockButton('blockquote', 'quote-left')}
{this.renderBlockButton('unordered_list', 'list-bullet', 'list_item')}
</div>
);
}
}
StylesMenu.propTypes = {
isOpen: PropTypes.bool.isRequired,
position: PropTypes.shape({
top: PropTypes.number.isRequired,
left: PropTypes.number.isRequired
}),
marks: PropTypes.object.isRequired,
blocks: PropTypes.object.isRequired,
inlines: PropTypes.object.isRequired,
onClickBlock: PropTypes.func.isRequired,
onClickMark: PropTypes.func.isRequired,
onClickInline: PropTypes.func.isRequired
};
export default withPortalAtCursorPosition(StylesMenu);

View File

@ -1,19 +1,27 @@
import React, { PropTypes } from 'react';
import _ from 'lodash';
import { Editor, Raw } from 'slate';
import position from 'selection-position';
import PluginDropImages from 'slate-drop-or-paste-images';
import MarkupIt, { SlateUtils } from 'markup-it';
import { emptyParagraphBlock } from '../constants';
import MediaProxy from '../../../../valueObjects/MediaProxy';
import { emptyParagraphBlock, mediaproxyBlock } from '../constants';
import { DEFAULT_NODE, SCHEMA } from './schema';
import { getNodes, getSyntaxes, getPlugins } from '../../richText';
import StylesMenu from './StylesMenu';
import BlockTypesMenu from './BlockTypesMenu';
//import styles from './index.css';
/**
* Slate Render Configuration
*/
class VisualEditor extends React.Component {
export default class VisualEditor extends React.Component {
static propTypes = {
onChange: PropTypes.func.isRequired,
onAddMedia: PropTypes.func.isRequired,
getMedia: PropTypes.func.isRequired,
value: PropTypes.string,
};
constructor(props) {
super(props);
@ -23,25 +31,10 @@ class VisualEditor extends React.Component {
SCHEMA.nodes = _.merge(SCHEMA.nodes, getNodes());
this.blockEdit = false;
this.menuPositions = {
stylesMenu: {
top: 0,
left: 0,
width: 0,
height: 0
},
blockTypesMenu: {
top: 0,
left: 0,
width: 0,
height: 0
}
};
let rawJson;
if (props.value !== undefined) {
const content = this.markdown.toContent(props.value);
console.log('md: %o', content);
rawJson = SlateUtils.encode(content, null, ['mediaproxy'].concat(getPlugins().map(plugin => plugin.id)));
} else {
rawJson = emptyParagraphBlock;
@ -50,8 +43,16 @@ class VisualEditor extends React.Component {
state: Raw.deserialize(rawJson, { terse: true })
};
this.calculateHoverMenuPosition = _.throttle(this.calculateHoverMenuPosition.bind(this), 30);
this.calculateBlockMenuPosition = _.throttle(this.calculateBlockMenuPosition.bind(this), 100);
this.plugins = [
PluginDropImages({
applyTransform: (transform, file) => {
const mediaProxy = new MediaProxy(file.name, file);
props.onAddMedia(mediaProxy);
return transform
.insertBlock(mediaproxyBlock(mediaProxy));
}
})
];
}
getMedia = src => {
@ -61,15 +62,14 @@ class VisualEditor extends React.Component {
/**
* Slate keeps track of selections, scroll position etc.
* So, onChange gets dispatched on every interaction (click, arrows, everything...)
* It also have an onDocumentChange, that get's dispached only when the actual
* It also have an onDocumentChange, that get's dispatched only when the actual
* content changes
*/
handleChange = state => {
if (this.blockEdit) {
this.blockEdit = false;
} else {
this.calculateHoverMenuPosition();
this.setState({ state }, this.calculateBlockMenuPosition);
this.setState({ state });
}
};
@ -79,32 +79,6 @@ class VisualEditor extends React.Component {
this.props.onChange(this.markdown.toText(content));
};
calculateHoverMenuPosition() {
const rect = position();
this.menuPositions.stylesMenu = {
top: rect.top + window.scrollY,
left: rect.left + window.scrollX,
width: rect.width,
height: rect.height
};
}
calculateBlockMenuPosition() {
// Don't bother calculating position if block is not empty
if (this.state.state.blocks.get(0).isEmpty) {
const blockElement = document.querySelectorAll(`[data-key='${this.state.state.selection.focusKey}']`);
if (blockElement.length > 0) {
const rect = blockElement[0].getBoundingClientRect();
this.menuPositions.blockTypesMenu = {
top: rect.top + window.scrollY,
left: rect.left + window.scrollX
};
// Force re-render so the menu is positioned on these new coordinates
this.forceUpdate();
}
}
}
/**
* Toggle marks / blocks when button is clicked
*/
@ -166,11 +140,11 @@ class VisualEditor extends React.Component {
};
/**
* When clicking a link, if the selection has a link in it, remove the link.
* Otherwise, add a new link with an href and text.
*
* @param {Event} e
*/
* When clicking a link, if the selection has a link in it, remove the link.
* Otherwise, add a new link with an href and text.
*
* @param {Event} e
*/
handleInlineClick = (type, isActive) => {
let { state } = this.state;
@ -186,7 +160,7 @@ class VisualEditor extends React.Component {
}
else {
const href = window.prompt('Enter the URL of the link:', 'http://www.');
const href = window.prompt('Enter the URL of the link:', 'http://www.'); // eslint-disable-line
state = state
.transform()
.wrapInline({
@ -204,12 +178,12 @@ class VisualEditor extends React.Component {
let { state } = this.state;
state = state
.transform()
.insertBlock({
type: type,
isVoid: true
})
.apply();
.transform()
.insertBlock({
type: type,
isVoid: true
})
.apply();
this.setState({ state }, this.focusAndAddParagraph);
};
@ -238,14 +212,7 @@ class VisualEditor extends React.Component {
state = state
.transform()
.insertInline({
type: 'mediaproxy',
isVoid: true,
data: { src: mediaProxy.public_path }
})
.collapseToEnd()
.insertBlock(DEFAULT_NODE)
.focus()
.insertBlock(mediaproxyBlock(mediaProxy))
.apply();
this.setState({ state });
@ -264,7 +231,7 @@ class VisualEditor extends React.Component {
.apply({
snapshot: false
});
this.setState({ state:normalized });
this.setState({ state: normalized });
};
handleKeyDown = evt => {
@ -272,9 +239,9 @@ class VisualEditor extends React.Component {
this.blockEdit = true;
let { state } = this.state;
state = state
.transform()
.insertText(' \n')
.apply();
.transform()
.insertText('\n')
.apply();
this.setState({ state });
}
@ -286,12 +253,11 @@ class VisualEditor extends React.Component {
return (
<BlockTypesMenu
isOpen={isOpen}
plugins={getPlugins()}
position={this.menuPositions.blockTypesMenu}
onClickBlock={this.handleBlockTypeClick}
onClickPlugin={this.handlePluginClick}
onClickImage={this.handleImageClick}
isOpen={isOpen}
plugins={getPlugins()}
onClickBlock={this.handleBlockTypeClick}
onClickPlugin={this.handlePluginClick}
onClickImage={this.handleImageClick}
/>
);
};
@ -302,14 +268,13 @@ class VisualEditor extends React.Component {
return (
<StylesMenu
isOpen={isOpen}
position={this.menuPositions.stylesMenu}
marks={this.state.state.marks}
blocks={this.state.state.blocks}
inlines={this.state.state.inlines}
onClickMark={this.handleMarkStyleClick}
onClickInline={this.handleInlineClick}
onClickBlock={this.handleBlockStyleClick}
isOpen={isOpen}
marks={this.state.state.marks}
blocks={this.state.state.blocks}
inlines={this.state.state.inlines}
onClickMark={this.handleMarkStyleClick}
onClickInline={this.handleInlineClick}
onClickBlock={this.handleBlockStyleClick}
/>
);
}
@ -320,23 +285,15 @@ class VisualEditor extends React.Component {
{this.renderStylesMenu()}
{this.renderBlockTypesMenu()}
<Editor
placeholder={'Enter some rich text...'}
state={this.state.state}
schema={SCHEMA}
onChange={this.handleChange}
onKeyDown={this.handleKeyDown}
onDocumentChange={this.handleDocumentChange}
placeholder={'Enter some rich text...'}
state={this.state.state}
schema={SCHEMA}
plugins={this.plugins}
onChange={this.handleChange}
onKeyDown={this.handleKeyDown}
onDocumentChange={this.handleDocumentChange}
/>
</div>
);
}
}
export default VisualEditor;
VisualEditor.propTypes = {
onChange: PropTypes.func.isRequired,
onAddMedia: PropTypes.func.isRequired,
getMedia: PropTypes.func.isRequired,
value: PropTypes.node,
};

View File

@ -0,0 +1,59 @@
import React from 'react';
import Portal from 'react-portal';
import position from 'selection-position';
export default function withPortalAtCursorPosition(WrappedComponent) {
return class extends React.Component {
static propTypes = {
isOpen: React.PropTypes.bool.isRequired
}
state = {
menu: null,
cursorPosition: null
}
componentDidMount() {
this.adjustPosition();
}
componentDidUpdate() {
this.adjustPosition();
}
adjustPosition = () => {
const { menu } = this.state;
if (!menu) return;
const cursorPosition = position(); // TODO: Results aren't determenistic
const centerX = Math.ceil(
cursorPosition.left
+ cursorPosition.width / 2
+ window.scrollX
- menu.offsetWidth / 2
);
const centerY = cursorPosition.top + window.scrollY;
menu.style.opacity = 1;
menu.style.top = `${centerY}px`;
menu.style.left = `${centerX}px`;
}
/**
* When the portal opens, cache the menu element.
*/
handleOpen = (portal) => {
this.setState({ menu: portal.firstChild });
}
render() {
const { isOpen, ...rest } = this.props;
return (
<Portal isOpened={isOpen} onOpen={this.handleOpen}>
<WrappedComponent {...rest}/>
</Portal>
);
}
};
}

View File

@ -1,6 +1,7 @@
export const emptyParagraphBlock = {
nodes: [
{ kind: 'block',
{
kind: 'block',
type: 'paragraph',
nodes: [{
kind: 'text',
@ -11,3 +12,17 @@ export const emptyParagraphBlock = {
}
]
};
export const mediaproxyBlock = mediaproxy => ({
kind: 'block',
type: 'paragraph',
nodes: [{
kind: 'inline',
type: 'mediaproxy',
isVoid: true,
data: {
alt: mediaproxy.name,
src: mediaproxy.public_path
}
}]
});

View File

@ -1,28 +1,34 @@
import React, { PropTypes } from 'react';
import MarkupIt from 'markup-it';
import { getSyntaxes } from './richText';
import MarkupItReactRenderer from '../MarkupItReactRenderer/index';
export default class MarkdownPreview extends React.Component {
constructor(props) {
super(props);
const { markdown, html } = getSyntaxes();
this.markdown = new MarkupIt(markdown);
this.html = new MarkupIt(html);
const MarkdownPreview = ({ value, getMedia }) => {
if (value == null) {
return null;
}
render() {
const { value } = this.props;
if (value == null) { return null; }
const content = this.markdown.toContent(value);
const contentHtml = { __html: this.html.toText(content) };
return (
<div dangerouslySetInnerHTML={contentHtml} />
);
}
}
const schema = {
'mediaproxy': ({ token }) => ( // eslint-disable-line
<img
src={getMedia(token.getIn(['data', 'src']))}
alt={token.getIn(['data', 'alt'])}
/>
)
};
const { markdown } = getSyntaxes();
return (
<MarkupItReactRenderer
value={value}
syntax={markdown}
schema={schema}
/>
);
};
MarkdownPreview.propTypes = {
value: PropTypes.node,
getMedia: PropTypes.func.isRequired,
value: PropTypes.string,
};
export default MarkdownPreview;

View File

@ -15,7 +15,6 @@ import { Icon } from '../UI';
let processedPlugins = List([]);
const nodes = {};
let augmentedMarkdownSyntax = markdownSyntax;
let augmentedHTMLSyntax = htmlSyntax;
@ -27,7 +26,7 @@ function processEditorPlugins(plugins) {
plugins.forEach(plugin => {
const basicRule = MarkupIt.Rule(plugin.id).regExp(plugin.pattern, (state, match) => (
{ data: plugin.fromBlock(match) }
{ data: plugin.fromBlock(match) }
));
const markdownRule = basicRule.toText((state, token) => (
@ -68,8 +67,8 @@ function processMediaProxyPlugins(getMedia) {
}
var imgData = Map({
alt: match[1],
src: match[2],
alt: match[1],
src: match[2],
title: match[3]
}).filter(Boolean);
@ -78,9 +77,9 @@ function processMediaProxyPlugins(getMedia) {
};
});
const mediaProxyMarkdownRule = mediaProxyRule.toText((state, token) => {
var data = token.getData();
var alt = data.get('alt', '');
var src = data.get('src', '');
var data = token.getData();
var alt = data.get('alt', '');
var src = data.get('src', '');
var title = data.get('title', '');
if (title) {
@ -90,9 +89,9 @@ function processMediaProxyPlugins(getMedia) {
}
});
const mediaProxyHTMLRule = mediaProxyRule.toText((state, token) => {
var data = token.getData();
var alt = data.get('alt', '');
var src = data.get('src', '');
var data = token.getData();
var alt = data.get('alt', '');
var src = data.get('src', '');
return `<img src=${getMedia(src)} alt=${alt} />`;
});
@ -103,7 +102,7 @@ function processMediaProxyPlugins(getMedia) {
const className = isFocused ? 'active' : null;
const src = node.data.get('src');
return (
<img {...props.attributes} src={getMedia(src)} className={className} />
<img {...props.attributes} src={getMedia(src)} className={className}/>
);
};
augmentedMarkdownSyntax = augmentedMarkdownSyntax.addInlineRules(mediaProxyMarkdownRule);
@ -111,9 +110,11 @@ function processMediaProxyPlugins(getMedia) {
}
function getPlugins() {
return processedPlugins.map(plugin => (
{ id: plugin.id, icon: plugin.icon, fields: plugin.fields }
)).toArray();
return processedPlugins.map(plugin => ({
id: plugin.id,
icon: plugin.icon,
fields: plugin.fields
})).toArray();
}
function getNodes() {
@ -124,7 +125,7 @@ function getSyntaxes(getMedia) {
if (getMedia) {
processMediaProxyPlugins(getMedia);
}
return { markdown: augmentedMarkdownSyntax, html:augmentedHTMLSyntax };
return { markdown: augmentedMarkdownSyntax, html: augmentedHTMLSyntax };
}
export { processEditorPlugins, getNodes, getSyntaxes, getPlugins };

View File

@ -1,7 +1,7 @@
import React from 'react';
import { storiesOf, action } from '@kadira/storybook';
import FindBar from '../UI/FindBar/FindBar';
import FindBar from '../FindBar/FindBar';
const CREATE_COLLECTION = 'CREATE_COLLECTION';
const CREATE_POST = 'CREATE_POST';

View File

@ -0,0 +1,34 @@
import React from 'react';
import markdownSyntax from 'markup-it/syntaxes/markdown';
import htmlSyntax from 'markup-it/syntaxes/html';
import MarkupItReactRenderer from '../MarkupItReactRenderer';
import { storiesOf } from '@kadira/storybook';
const mdContent = `
# Title
* List 1
* List 2
`;
const htmlContent = `
<h1>Title</h1>
<ol>
<li>List item 1</li>
<li>List item 2</li>
</ol>
`;
storiesOf('MarkupItReactRenderer', module)
.add('Markdown', () => (
<MarkupItReactRenderer
value={mdContent}
syntax={markdownSyntax}
/>
)).add('HTML', () => (
<MarkupItReactRenderer
value={htmlContent}
syntax={htmlSyntax}
/>
));

View File

@ -0,0 +1,55 @@
import React from 'react';
import ScrollSync from '../ScrollSync/ScrollSync';
import ScrollSyncPane from '../ScrollSync/ScrollSyncPane';
import { storiesOf } from '@kadira/storybook';
const paneStyle = {
border: '1px solid green',
overflow: 'auto',
};
storiesOf('ScrollSync', module)
.add('Default', () => (
<ScrollSync>
<div style={{ display: 'flex', position: 'relative', height: 500 }}>
<ScrollSyncPane>
<div style={paneStyle}>
<section style={{ height: 5000 }}>
<h1>Left Pane Content</h1>
<p>
Lorem ipsum dolor sit amet, consectetur adipisicing elit. Ab aperiam doloribus
dolorum est eum eveniet exercitationem iste labore minus, neque nobis odit officiis
omnis possimus quasi rerum sed soluta veritatis.
</p>
</section>
</div>
</ScrollSyncPane>
<ScrollSyncPane>
<div style={paneStyle}>
<section style={{ height: 10000 }}>
<h1>Right Pane Content</h1>
<p>
Lorem ipsum dolor sit amet, consectetur adipisicing elit. Ab aperiam doloribus
dolorum est eum eveniet exercitationem iste labore minus, neque nobis odit officiis
omnis possimus quasi rerum sed soluta veritatis.
</p>
</section>
</div>
</ScrollSyncPane>
<ScrollSyncPane>
<div style={paneStyle}>
<section style={{ height: 2000 }}>
<h1>Third Pane Content</h1>
<p>
Lorem ipsum dolor sit amet, consectetur adipisicing elit. Ab aperiam doloribus
dolorum est eum eveniet exercitationem iste labore minus, neque nobis odit officiis
omnis possimus quasi rerum sed soluta veritatis.
</p>
</section>
</div>
</ScrollSyncPane>
</div>
</ScrollSync>
));

View File

@ -1,19 +1,41 @@
import React from 'react';
import { Toast } from '../UI';
import { storiesOf } from '@kadira/storybook';
import { Toast } from '../UI';
const containerStyle = {
position: 'fixed',
top: 0,
right: 0,
width: 360,
height: '100%',
};
storiesOf('Toast', module)
.add('All kinds stacked', () => (
<div style={containerStyle}>
<Toast kind="info" message="A Toast Message" />
<Toast kind="success" message="A Toast Message" />
<Toast kind="warning" message="A Toast Message" />
<Toast kind="danger" message="A Toast Message" />
</div>
))
.add('Info', () => (
<div style={containerStyle}>
<Toast kind="info" message="A Toast Message" />
</div>
))
.add('Success', () => (
<div>
<Toast type='success' show>A Toast Message</Toast>
<div style={containerStyle}>
<Toast kind="success" message="A Toast Message" />
</div>
)).add('Waring', () => (
<div>
<Toast type='warning' show>A Toast Message</Toast>
))
.add('Waring', () => (
<div style={containerStyle}>
<Toast kind="warning" message="A Toast Message" />
</div>
)).add('Error', () => (
<div>
<Toast type='error' show>A Toast Message</Toast>
))
.add('Error', () => (
<div style={containerStyle}>
<Toast kind="danger" message="A Toast Message" />
</div>
));

View File

@ -2,3 +2,5 @@ import './Card';
import './Icon';
import './Toast';
import './FindBar';
import './MarkupItReactRenderer';
import './ScrollSync';

View File

@ -1,20 +1,30 @@
@import '../components/UI/theme.css';
.layout .navDrawer .drawerContent {
padding-top: 54px;
max-width: 240px;
}
.nav {
display: block;
padding: 1rem;
& .heading {
border: none;
}
}
.main {
padding-top: 54px;
}
.navDrawer {
max-width: 240px !important;
& .drawerContent {
max-width: 240px !important;
}
.notifsContainer {
position: fixed;
top: 60px;
right: 0;
bottom: 60px;
z-index: var(--topmostZindex);
width: 360px;
pointer-events: none;
}

View File

@ -1,7 +1,11 @@
import React from 'react';
import React, { PropTypes } from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import pluralize from 'pluralize';
import { connect } from 'react-redux';
import { Layout, Panel, NavDrawer, Navigation, Link } from 'react-toolbox';
import { Layout, Panel, NavDrawer } from 'react-toolbox/lib/layout';
import { Navigation } from 'react-toolbox/lib/navigation';
import { Link } from 'react-toolbox/lib/link';
import { Notifs } from 'redux-notifications';
import { loadConfig } from '../actions/config';
import { loginUser } from '../actions/auth';
import { currentBackend } from '../backends/backend';
@ -11,39 +15,45 @@ import {
HELP,
runCommand,
navigateToCollection,
createNewEntryInCollection
createNewEntryInCollection,
} from '../actions/findbar';
import AppHeader from '../components/AppHeader/AppHeader';
import { Loader } from '../components/UI/index';
import { Loader, Toast } from '../components/UI/index';
import styles from './App.css';
class App extends React.Component {
static propTypes = {
auth: ImmutablePropTypes.map,
children: PropTypes.node,
config: ImmutablePropTypes.map,
collections: ImmutablePropTypes.orderedMap,
createNewEntryInCollection: PropTypes.func.isRequired,
dispatch: PropTypes.func.isRequired,
navigateToCollection: PropTypes.func.isRequired,
user: ImmutablePropTypes.map,
runCommand: PropTypes.func.isRequired,
};
static configError(config) {
return (<div>
<h1>Error loading the CMS configuration</h1>
<div>
<p>The <code>config.yml</code> file could not be loaded or failed to parse properly.</p>
<p><strong>Error message:</strong> {config.get('error')}</p>
</div>
</div>);
}
state = {
navDrawerIsVisible: true
navDrawerIsVisible: false,
};
componentDidMount() {
this.props.dispatch(loadConfig());
}
configError(config) {
return <div>
<h1>Error loading the CMS configuration</h1>
<div>
<p>The "config.yml" file could not be loaded or failed to parse properly.</p>
<p><strong>Error message:</strong> {config.get('error')}</p>
</div>
</div>;
}
configLoading() {
return <div>
<Loader active>Loading configuration...</Loader>
</div>;
}
handleLogin(credentials) {
this.props.dispatch(loginUser(credentials));
}
@ -56,13 +66,17 @@ class App extends React.Component {
return <div><h1>Waiting for backend...</h1></div>;
}
return <div>
{React.createElement(backend.authComponent(), {
onLogin: this.handleLogin.bind(this),
error: auth && auth.get('error'),
isFetching: auth && auth.get('isFetching')
})}
</div>;
return (
<div>
{
React.createElement(backend.authComponent(), {
onLogin: this.handleLogin.bind(this),
error: auth && auth.get('error'),
isFetching: auth && auth.get('isFetching'),
})
}
</div>
);
}
generateFindBarCommands() {
@ -70,22 +84,22 @@ class App extends React.Component {
const commands = [];
const defaultCommands = [];
this.props.collections.forEach(collection => {
this.props.collections.forEach((collection) => {
commands.push({
id: `show_${collection.get('name')}`,
pattern: `Show ${pluralize(collection.get('label'))}`,
id: `show_${ collection.get('name') }`,
pattern: `Show ${ pluralize(collection.get('label')) }`,
type: SHOW_COLLECTION,
payload: { collectionName: collection.get('name') }
payload: { collectionName: collection.get('name') },
});
if (defaultCommands.length < 5) defaultCommands.push(`show_${collection.get('name')}`);
if (defaultCommands.length < 5) defaultCommands.push(`show_${ collection.get('name') }`);
if (collection.get('create') === true) {
commands.push({
id: `create_${collection.get('name')}`,
pattern: `Create new ${pluralize(collection.get('label'), 1)}(:itemName as ${pluralize(collection.get('label'), 1)} Name)`,
id: `create_${ collection.get('name') }`,
pattern: `Create new ${ pluralize(collection.get('label'), 1) }(:itemName as ${ pluralize(collection.get('label'), 1) } Name)`,
type: CREATE_COLLECTION,
payload: { collectionName: collection.get('name') }
payload: { collectionName: collection.get('name') },
});
}
});
@ -98,7 +112,7 @@ class App extends React.Component {
toggleNavDrawer = () => {
this.setState({
navDrawerIsVisible: !this.state.navDrawerIsVisible
navDrawerIsVisible: !this.state.navDrawerIsVisible,
});
};
@ -111,7 +125,7 @@ class App extends React.Component {
collections,
runCommand,
navigateToCollection,
createNewEntryInCollection
createNewEntryInCollection,
} = this.props;
if (config === null) {
@ -119,11 +133,11 @@ class App extends React.Component {
}
if (config.get('error')) {
return this.configError(config);
return App.configError(config);
}
if (config.get('isFetching')) {
return this.configLoading();
return <Loader active>Loading configuration...</Loader>;
}
if (user == null) {
@ -134,20 +148,25 @@ class App extends React.Component {
return (
<Layout theme={styles}>
<Notifs
className={styles.notifsContainer}
CustomComponent={Toast}
/>
<NavDrawer
active={navDrawerIsVisible}
scrollY
permanentAt={navDrawerIsVisible ? 'lg' : null}
theme={styles}
active={navDrawerIsVisible}
scrollY
permanentAt="lg"
onOverlayClick={this.toggleNavDrawer} // eslint-disable-line
theme={styles}
>
<nav className={styles.nav}>
<h1 className={styles.heading}>Collections</h1>
<Navigation type='vertical'>
<Navigation type="vertical">
{
collections.valueSeq().map(collection =>
<Link
key={collection.get('name')}
onClick={navigateToCollection.bind(this, collection.get('name'))}
key={collection.get('name')}
onClick={navigateToCollection.bind(this, collection.get('name'))} // eslint-disable-line
>
{collection.get('label')}
</Link>
@ -158,12 +177,12 @@ class App extends React.Component {
</NavDrawer>
<Panel scrollY>
<AppHeader
collections={collections}
commands={commands}
defaultCommands={defaultCommands}
runCommand={runCommand}
onCreateEntryClick={createNewEntryInCollection}
toggleNavDrawer={this.toggleNavDrawer}
collections={collections}
commands={commands}
defaultCommands={defaultCommands}
runCommand={runCommand}
onCreateEntryClick={createNewEntryInCollection}
toggleNavDrawer={this.toggleNavDrawer}
/>
<div className={styles.main}>
{children}
@ -192,7 +211,7 @@ function mapDispatchToProps(dispatch) {
},
createNewEntryInCollection: (collectionName) => {
dispatch(createNewEntryInCollection(collectionName));
}
},
};
}

View File

@ -7,12 +7,14 @@ import {
createEmptyDraft,
discardDraft,
changeDraft,
persistEntry
persistEntry,
} from '../actions/entries';
import { cancelEdit } from '../actions/editor';
import { addMedia, removeMedia } from '../actions/media';
import { selectEntry, getMedia } from '../reducers';
import EntryEditor from '../components/EntryEditor';
import EntryPageHOC from './editorialWorkflow/EntryPageHOC';
import EntryEditor from '../components/EntryEditor/EntryEditor';
import entryPageHOC from './editorialWorkflow/EntryPageHOC';
import { Loader } from '../components/UI';
class EntryPage extends React.Component {
static propTypes = {
@ -28,17 +30,18 @@ class EntryPage extends React.Component {
loadEntry: PropTypes.func.isRequired,
persistEntry: PropTypes.func.isRequired,
removeMedia: PropTypes.func.isRequired,
cancelEdit: PropTypes.func.isRequired,
slug: PropTypes.string,
newEntry: PropTypes.bool.isRequired,
};
componentDidMount() {
const { entry, collection, slug } = this.props;
const { entry, newEntry, collection, slug, createEmptyDraft, loadEntry } = this.props;
if (this.props.newEntry) {
this.props.createEmptyDraft(this.props.collection);
if (newEntry) {
createEmptyDraft(collection);
} else {
this.props.loadEntry(entry, collection, slug);
loadEntry(entry, collection, slug);
this.createDraft(entry);
}
}
@ -56,43 +59,47 @@ class EntryPage extends React.Component {
this.props.discardDraft();
}
createDraft = entry => {
createDraft = (entry) => {
if (entry) this.props.createDraftFromEntry(entry);
};
handlePersistEntry = () => {
this.props.persistEntry(this.props.collection, this.props.entryDraft);
const { persistEntry, collection, entryDraft } = this.props;
persistEntry(collection, entryDraft);
};
render() {
const {
entry, entryDraft, boundGetMedia, collection, changeDraft, addMedia, removeMedia
entry,
entryDraft,
boundGetMedia,
collection,
changeDraft,
addMedia,
removeMedia,
cancelEdit,
} = this.props;
if (entryDraft == null || entryDraft.get('entry') == undefined || entry && entry.get('isFetching')) {
return <div>Loading...</div>;
if (entryDraft == null
|| entryDraft.get('entry') === undefined
|| (entry && entry.get('isFetching'))) {
return <Loader active>Loading entry...</Loader>;
}
return (
<EntryEditor
entry={entryDraft.get('entry')}
getMedia={boundGetMedia}
collection={collection}
onChange={changeDraft}
onAddMedia={addMedia}
onRemoveMedia={removeMedia}
onPersist={this.handlePersistEntry}
entry={entryDraft.get('entry')}
getMedia={boundGetMedia}
collection={collection}
onChange={changeDraft}
onAddMedia={addMedia}
onRemoveMedia={removeMedia}
onPersist={this.handlePersistEntry}
onCancelEdit={cancelEdit}
/>
);
}
}
/*
* Instead of checking the publish mode everywhere to dispatch & render the additional editorial workflow stuff,
* We delegate it to a Higher Order Component
*/
EntryPage = EntryPageHOC(EntryPage);
function mapStateToProps(state, ownProps) {
const { collections, entryDraft } = state;
const collection = collections.get(ownProps.params.name);
@ -100,7 +107,15 @@ function mapStateToProps(state, ownProps) {
const slug = ownProps.params.slug;
const entry = newEntry ? null : selectEntry(state, collection.get('name'), slug);
const boundGetMedia = getMedia.bind(null, state);
return { collection, collections, newEntry, entryDraft, boundGetMedia, slug, entry };
return {
collection,
collections,
newEntry,
entryDraft,
boundGetMedia,
slug,
entry,
};
}
export default connect(
@ -113,6 +128,7 @@ export default connect(
createDraftFromEntry,
createEmptyDraft,
discardDraft,
persistEntry
persistEntry,
cancelEdit,
}
)(EntryPage);
)(entryPageHOC(EntryPage));

View File

@ -32,9 +32,9 @@ export default function CollectionPageHOC(CollectionPage) {
<div>
<div className={styles.root}>
<UnpublishedListing
entries={unpublishedEntries}
handleChangeStatus={updateUnpublishedEntryStatus}
handlePublish={publishUnpublishedEntry}
entries={unpublishedEntries}
handleChangeStatus={updateUnpublishedEntryStatus}
handlePublish={publishUnpublishedEntry}
/>
</div>
{super.render()}

View File

@ -36,59 +36,6 @@ h1 {
font-size: 25px;
}
:global {
& .cms-widget {
border-bottom: 1px solid #e8eae8;
position: relative;
}
& .cms-widget:after {
content: '';
position: absolute;
left: 42px;
bottom: -7px;
width: 12px;
height: 12px;
background-color: #f2f5f4;
-webkit-transform: rotate(45deg);
transform: rotate(45deg);
z-index: 1;
border-right: 1px solid #e8eae8;
border-bottom: 1px solid #e8eae8;
}
& .cms-widget:last-child {
border-bottom: none;
}
& .cms-widget:last-child:after {
display: none;
}
& .cms-control {
color: #7c8382;
position: relative;
padding: 20px 0;
& label {
color: #AAB0AF;
font-size: 12px;
margin-bottom: 18px;
}
& input,
& textarea,
& select,
& .cms-editor-raw {
font-family: monospace;
display: block;
width: 100%;
padding: 0;
margin: 0;
border: none;
outline: 0;
box-shadow: none;
background: 0 0;
font-size: 18px;
color: #7c8382;
}
}
}
:global {
& .rdt {
position: relative;

View File

@ -1,4 +1,3 @@
import expect from 'expect';
import Immutable from 'immutable';
import { configLoaded, configLoading, configFailed } from '../../actions/config';
import config from '../config';
@ -14,9 +13,9 @@ describe('config', () => {
it('should handle an update', () => {
expect(
config(Immutable.Map({ 'a': 'b', 'c': 'd' }), configLoaded({ 'a': 'changed', 'e': 'new' }))
config(Immutable.Map({ a: 'b', c: 'd' }), configLoaded({ a: 'changed', e: 'new' }))
).toEqual(
Immutable.Map({ 'a': 'changed', 'e': 'new' })
Immutable.Map({ a: 'changed', e: 'new' })
);
});

View File

@ -1,32 +1,29 @@
import expect from 'expect';
import { Map, OrderedMap, fromJS } from 'immutable';
import { entriesLoading, entriesLoaded } from '../../actions/entries';
import * as actions from '../../actions/entries';
import reducer from '../entries';
const initialState = OrderedMap({
posts: Map({ name: 'posts' }),
});
describe('entries', () => {
it('should mark entries as fetching', () => {
const state = OrderedMap({
'posts': Map({ name: 'posts' })
});
expect(
reducer(state, entriesLoading(Map({ name: 'posts' })))
reducer(initialState, actions.entriesLoading(Map({ name: 'posts' })))
).toEqual(
OrderedMap(fromJS({
'posts': { name: 'posts' },
'pages': {
'posts': { isFetching: true }
}
posts: { name: 'posts' },
pages: {
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(
reducer(state, entriesLoaded(Map({ name: 'posts' }), entries))
reducer(initialState, actions.entriesLoaded(Map({ name: 'posts' }), entries, 0))
).toEqual(
OrderedMap(fromJS(
{
@ -37,9 +34,10 @@ describe('entries', () => {
},
pages: {
posts: {
ids: ['a', 'b']
}
}
page: 0,
ids: ['a', 'b'],
},
},
}
))
);

View File

@ -0,0 +1,126 @@
import { Map, List, fromJS } from 'immutable';
import * as actions from '../../actions/entries';
import reducer from '../entryDraft';
let initialState = Map({ entry: Map(), mediaFiles: List() });
const entry = {
collection: 'posts',
slug: 'slug',
path: 'content/blog/art-and-wine-festival.md',
partial: false,
raw: '',
data: {},
metaData: null,
};
describe('entryDraft reducer', () => {
describe('DRAFT_CREATE_FROM_ENTRY', () => {
it('should create draft from the entry', () => {
expect(
reducer(
initialState,
actions.createDraftFromEntry(fromJS(entry))
)
).toEqual(
fromJS({
entry: {
...entry,
newRecord: false,
},
mediaFiles: [],
})
);
});
});
describe('DRAFT_CREATE_EMPTY', () => {
it('should create a new draft ', () => {
expect(
reducer(
initialState,
actions.emmptyDraftCreated(fromJS(entry))
)
).toEqual(
fromJS({
entry: {
...entry,
newRecord: true,
},
mediaFiles: [],
})
);
});
});
describe('DRAFT_DISCARD', () => {
it('should discard the draft and return initial state', () => {
expect(reducer(initialState, actions.discardDraft()))
.toEqual(initialState);
});
});
describe('DRAFT_CHANGE', () => {
it.skip('should update the draft', () => {
const newEntry = {
...entry,
raw: 'updated',
};
expect(reducer(initialState, actions.changeDraft(newEntry)))
.toEqual(fromJS({
entry: {
...entry,
raw: 'updated',
},
mediaFiles: [],
}));
});
});
describe('persisting', () => {
beforeEach(() => {
initialState = fromJS({
entities: {
'posts.slug': {
collection: 'posts',
slug: 'slug',
path: 'content/blog/art-and-wine-festival.md',
partial: false,
raw: '',
data: {},
metaData: null,
},
},
pages: {},
});
});
it('should handle persisting request', () => {
const newState = reducer(
initialState,
actions.entryPersisting(Map({ name: 'posts' }), Map({ slug: 'slug' }))
);
expect(newState.getIn(['entry', 'isPersisting'])).toBe(true);
});
it('should handle persisting success', () => {
let newState = reducer(initialState,
actions.entryPersisting(Map({ name: 'posts' }), Map({ slug: 'slug' }))
);
newState = reducer(newState,
actions.entryPersisted(Map({ name: 'posts' }), Map({ slug: 'slug' }))
);
expect(newState.getIn(['entry', 'isPersisting'])).toBeUndefined();
});
it('should handle persisting error', () => {
let newState = reducer(initialState,
actions.entryPersisting(Map({ name: 'posts' }), Map({ slug: 'slug' }))
);
newState = reducer(newState,
actions.entryPersistFail(Map({ name: 'posts' }), Map({ slug: 'slug' }), 'Error message')
);
expect(newState.getIn(['entry', 'isPersisting'])).toBeUndefined();
});
});
});

View File

@ -8,7 +8,6 @@ const auth = (state = null, action) => {
case AUTH_SUCCESS:
return Immutable.fromJS({ user: action.payload });
case AUTH_FAILURE:
console.error(action.payload);
return Immutable.Map({ error: action.payload.toString() });
default:
return state;

View File

@ -1,8 +1,10 @@
import { combineReducers } from 'redux';
import { routerReducer } from 'react-router-redux';
import { reducer as notifReducer } from 'redux-notifications';
import reducers from '.';
export default combineReducers({
...reducers,
routing: routerReducer
notifs: notifReducer,
routing: routerReducer,
});

View File

@ -1,18 +1,26 @@
import { Map, List, fromJS } from 'immutable';
import {
ENTRY_REQUEST, ENTRY_SUCCESS, ENTRIES_REQUEST, ENTRIES_SUCCESS, SEARCH_ENTRIES_REQUEST, SEARCH_ENTRIES_SUCCESS
ENTRY_REQUEST,
ENTRY_SUCCESS,
ENTRIES_REQUEST,
ENTRIES_SUCCESS,
SEARCH_ENTRIES_REQUEST,
SEARCH_ENTRIES_SUCCESS,
} from '../actions/entries';
let collection, loadedEntries, page, searchTerm;
let collection;
let loadedEntries;
let page;
let searchTerm;
const entries = (state = Map({ entities: Map(), pages: Map() }), action) => {
switch (action.type) {
case ENTRY_REQUEST:
return state.setIn(['entities', `${action.payload.collection}.${action.payload.slug}`, 'isFetching'], true);
return state.setIn(['entities', `${ action.payload.collection }.${ action.payload.slug }`, 'isFetching'], true);
case ENTRY_SUCCESS:
return state.setIn(
['entities', `${action.payload.collection}.${action.payload.entry.slug}`],
['entities', `${ action.payload.collection }.${ action.payload.entry.slug }`],
fromJS(action.payload.entry)
);
@ -24,15 +32,15 @@ const entries = (state = Map({ entities: Map(), pages: Map() }), action) => {
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))
loadedEntries.forEach(entry => (
map.setIn(['entities', `${ collection }.${ entry.slug }`], fromJS(entry).set('isFetching', false))
));
const ids = List(loadedEntries.map((entry) => entry.slug));
const ids = List(loadedEntries.map(entry => entry.slug));
map.setIn(['pages', collection], Map({
page: page,
ids: page === 0 ? ids : map.getIn(['pages', collection, 'ids'], List()).concat(ids)
page,
ids: page === 0 ? ids : map.getIn(['pages', collection, 'ids'], List()).concat(ids),
}));
});
@ -42,23 +50,22 @@ const entries = (state = Map({ entities: Map(), pages: Map() }), action) => {
map.setIn(['search', 'isFetching'], true);
map.setIn(['search', 'term'], action.payload.searchTerm);
});
} else {
return state;
}
return state;
case SEARCH_ENTRIES_SUCCESS:
loadedEntries = action.payload.entries;
page = action.payload.page;
searchTerm = action.payload.searchTerm;
return state.withMutations((map) => {
loadedEntries.forEach((entry) => (
map.setIn(['entities', `${entry.collection}.${entry.slug}`], fromJS(entry).set('isFetching', false))
loadedEntries.forEach(entry => (
map.setIn(['entities', `${ entry.collection }.${ entry.slug }`], fromJS(entry).set('isFetching', false))
));
const ids = List(loadedEntries.map(entry => ({ collection: entry.collection, slug: entry.slug })));
map.set('search', Map({
page: page,
page,
term: searchTerm,
ids: page === 0 ? ids : map.getIn(['search', 'ids'], List()).concat(ids)
ids: page === 0 ? ids : map.getIn(['search', 'ids'], List()).concat(ids),
}));
});
@ -68,12 +75,12 @@ const entries = (state = Map({ entities: Map(), pages: Map() }), action) => {
};
export const selectEntry = (state, collection, slug) => (
state.getIn(['entities', `${collection}.${slug}`])
state.getIn(['entities', `${ collection }.${ slug }`])
);
export const selectEntries = (state, collection) => {
const slugs = state.getIn(['pages', collection, 'ids']);
return slugs && slugs.map((slug) => selectEntry(state, collection, slug));
return slugs && slugs.map(slug => selectEntry(state, collection, slug));
};
export const selectSearchedEntries = (state) => {

View File

@ -1,10 +1,21 @@
import { Map, List, fromJS } from 'immutable';
import { DRAFT_CREATE_FROM_ENTRY, DRAFT_CREATE_EMPTY, DRAFT_DISCARD, DRAFT_CHANGE } from '../actions/entries';
import { ADD_MEDIA, REMOVE_MEDIA } from '../actions/media';
import {
DRAFT_CREATE_FROM_ENTRY,
DRAFT_CREATE_EMPTY,
DRAFT_DISCARD,
DRAFT_CHANGE,
ENTRY_PERSIST_REQUEST,
ENTRY_PERSIST_SUCCESS,
ENTRY_PERSIST_FAILURE,
} from '../actions/entries';
import {
ADD_MEDIA,
REMOVE_MEDIA,
} from '../actions/media';
const initialState = Map({ entry: Map(), mediaFiles: List() });
const entryDraft = (state = Map(), action) => {
const entryDraftReducer = (state = Map(), action) => {
switch (action.type) {
case DRAFT_CREATE_FROM_ENTRY:
// Existing Entry
@ -25,14 +36,23 @@ const entryDraft = (state = Map(), action) => {
case DRAFT_CHANGE:
return state.set('entry', action.payload);
case ENTRY_PERSIST_REQUEST: {
return state.setIn(['entry', 'isPersisting'], true);
}
case ENTRY_PERSIST_SUCCESS:
case ENTRY_PERSIST_FAILURE: {
return state.deleteIn(['entry', 'isPersisting']);
}
case ADD_MEDIA:
return state.update('mediaFiles', (list) => list.push(action.payload.public_path));
return state.update('mediaFiles', list => list.push(action.payload.public_path));
case REMOVE_MEDIA:
return state.update('mediaFiles', (list) => list.filterNot((path) => path === action.payload));
return state.update('mediaFiles', list => list.filterNot(path => path === action.payload));
default:
return state;
}
};
export default entryDraft;
export default entryDraftReducer;

View File

@ -1,23 +1,40 @@
/* eslint global-require: 0 */
/* eslint import/no-extraneous-dependencies: 0 */
process.env.BABEL_ENV = 'test';
module.exports = wallaby => ({
files: [
{ pattern: 'src/**/*.js' },
{ pattern: 'src/**/*.spec.js', ignore: true }
'package.json',
'src/**/*.js',
'src/**/*.js.snap',
'!src/**/*.spec.js',
],
tests: [
{ pattern: 'src/**/*.spec.js' }
],
tests: ['src/**/*.spec.js'],
compilers: {
'src/**/*.js': wallaby.compilers.babel()
'src/**/*.js': wallaby.compilers.babel(),
},
env: {
type: 'node',
runner: 'node'
runner: 'node',
params: {
runner: '--harmony_proxies',
},
},
testFramework: 'jest',
setup: () => {
wallaby.testFramework.configure({
moduleNameMapper: {
'^.+\\.(png|eot|woff|woff2|ttf|svg|gif)$': require('path').join(wallaby.localProjectDir, '__mocks__', 'fileLoaderMock.js'),
'^.+\\.scss$': require('path').join(wallaby.localProjectDir, '__mocks__', 'styleLoaderMock.js'),
'^.+\\.css$': require('identity-obj-proxy'),
},
});
},
testFramework: 'jest'
});

View File

@ -6,7 +6,7 @@ module.exports = {
module: {
loaders: [
{
test: /\.((png)|(eot)|(woff)|(woff2)|(ttf)|(svg)|(gif))(\?v=\d+\.\d+\.\d+)?$/,
test: /\.(png|eot|woff|woff2|ttf|svg|gif)(\?v=\d+\.\d+\.\d+)?$/,
loader: 'url-loader?limit=100000',
},
{