Merge pull request #82 from netlify/markitup-react
Entry Editor Improvements
This commit is contained in:
commit
009c881290
3
__mocks__/fileLoaderMock.js
Normal file
3
__mocks__/fileLoaderMock.js
Normal file
@ -0,0 +1,3 @@
|
||||
// See http://facebook.github.io/jest/docs/tutorial-webpack.html#content
|
||||
|
||||
module.exports = 'test-file-stub';
|
3
__mocks__/styleLoaderMock.js
Normal file
3
__mocks__/styleLoaderMock.js
Normal file
@ -0,0 +1,3 @@
|
||||
// See http://facebook.github.io/jest/docs/tutorial-webpack.html#content
|
||||
|
||||
module.exports = {};
|
18
package.json
18
package.json
@ -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"
|
||||
}
|
||||
}
|
||||
|
@ -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();
|
||||
};
|
||||
}
|
||||
|
@ -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))
|
||||
);
|
||||
};
|
||||
}
|
||||
|
@ -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)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
@ -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,
|
||||
};
|
53
src/components/ControlPanel/ControlPane.css
Normal file
53
src/components/ControlPanel/ControlPane.css
Normal 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;
|
||||
}
|
||||
}
|
62
src/components/ControlPanel/ControlPane.js
Normal file
62
src/components/ControlPanel/ControlPane.js
Normal 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,
|
||||
};
|
@ -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%;
|
||||
}
|
@ -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,
|
||||
};
|
36
src/components/EntryEditor/EntryEditor.css
Normal file
36
src/components/EntryEditor/EntryEditor.css
Normal 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;
|
||||
}
|
65
src/components/EntryEditor/EntryEditor.js
Normal file
65
src/components/EntryEditor/EntryEditor.js
Normal 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,
|
||||
};
|
35
src/components/EntryEditor/EntryEditorToolbar.js
Normal file
35
src/components/EntryEditor/EntryEditorToolbar.js
Normal 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;
|
@ -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();
|
||||
});
|
||||
});
|
@ -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>"`;
|
@ -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}
|
||||
|
@ -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
|
||||
|
||||

|
||||
|
||||
###### 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
|
||||
|
||||

|
||||
`;
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
@ -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> & <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>"`;
|
106
src/components/MarkupItReactRenderer/index.js
Normal file
106
src/components/MarkupItReactRenderer/index.js
Normal 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
|
||||
]))
|
||||
};
|
@ -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,
|
||||
};
|
21
src/components/PreviewPane/Preview.js
Normal file
21
src/components/PreviewPane/Preview.js
Normal 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,
|
||||
};
|
@ -1,6 +1,6 @@
|
||||
.frame {
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
height: 100%;
|
||||
border: none;
|
||||
background: #fff;
|
||||
}
|
75
src/components/PreviewPane/PreviewPane.js
Normal file
75
src/components/PreviewPane/PreviewPane.js
Normal 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,
|
||||
};
|
81
src/components/ScrollSync/ScrollSync.js
Normal file
81
src/components/ScrollSync/ScrollSync.js
Normal 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);
|
||||
}
|
||||
}
|
28
src/components/ScrollSync/ScrollSyncPane.js
Normal file
28
src/components/ScrollSync/ScrollSyncPane.js
Normal 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;
|
||||
}
|
||||
}
|
2
src/components/ScrollSync/index.js
Normal file
2
src/components/ScrollSync/index.js
Normal file
@ -0,0 +1,2 @@
|
||||
export { default as ScrollSync } from './ScrollSync';
|
||||
export { default as ScrollSyncPane } from './ScrollSyncPane';
|
@ -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>
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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,
|
||||
};
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -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(
|
||||
|
@ -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;
|
||||
}
|
@ -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\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,
|
||||
};
|
||||
|
@ -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);
|
||||
|
@ -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);
|
||||
|
@ -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,
|
||||
};
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
@ -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
|
||||
}
|
||||
}]
|
||||
});
|
||||
|
@ -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;
|
||||
|
@ -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 };
|
||||
|
@ -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';
|
||||
|
34
src/components/stories/MarkupItReactRenderer.js
Normal file
34
src/components/stories/MarkupItReactRenderer.js
Normal 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}
|
||||
/>
|
||||
));
|
55
src/components/stories/ScrollSync.js
Normal file
55
src/components/stories/ScrollSync.js
Normal 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>
|
||||
));
|
@ -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>
|
||||
));
|
||||
|
@ -2,3 +2,5 @@ import './Card';
|
||||
import './Icon';
|
||||
import './Toast';
|
||||
import './FindBar';
|
||||
import './MarkupItReactRenderer';
|
||||
import './ScrollSync';
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
|
@ -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));
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -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));
|
||||
|
@ -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()}
|
||||
|
@ -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;
|
||||
|
@ -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' })
|
||||
);
|
||||
});
|
||||
|
||||
|
@ -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'],
|
||||
},
|
||||
},
|
||||
}
|
||||
))
|
||||
);
|
||||
|
126
src/reducers/__tests__/entryDraft.spec.js
Normal file
126
src/reducers/__tests__/entryDraft.spec.js
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
@ -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;
|
||||
|
@ -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,
|
||||
});
|
||||
|
@ -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) => {
|
||||
|
@ -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;
|
||||
|
@ -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'
|
||||
});
|
||||
|
@ -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',
|
||||
},
|
||||
{
|
||||
|
Loading…
x
Reference in New Issue
Block a user