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",
|
"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": [
|
"keywords": [
|
||||||
"netlify",
|
"netlify",
|
||||||
"cms"
|
"cms"
|
||||||
@ -39,8 +46,8 @@
|
|||||||
"author": "Netlify",
|
"author": "Netlify",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@kadira/storybook": "^1.36.0",
|
|
||||||
"babel-core": "^6.5.1",
|
"babel-core": "^6.5.1",
|
||||||
|
"babel-jest": "^15.0.0",
|
||||||
"babel-loader": "^6.2.2",
|
"babel-loader": "^6.2.2",
|
||||||
"babel-plugin-lodash": "^3.2.0",
|
"babel-plugin-lodash": "^3.2.0",
|
||||||
"babel-plugin-transform-class-properties": "^6.5.2",
|
"babel-plugin-transform-class-properties": "^6.5.2",
|
||||||
@ -50,11 +57,14 @@
|
|||||||
"babel-preset-react": "^6.5.0",
|
"babel-preset-react": "^6.5.0",
|
||||||
"babel-runtime": "^6.5.0",
|
"babel-runtime": "^6.5.0",
|
||||||
"css-loader": "^0.23.1",
|
"css-loader": "^0.23.1",
|
||||||
|
"enzyme": "^2.4.1",
|
||||||
"eslint": "^3.7.1",
|
"eslint": "^3.7.1",
|
||||||
"eslint-config-netlify": "github:netlify/eslint-config-netlify",
|
"eslint-config-netlify": "github:netlify/eslint-config-netlify",
|
||||||
"expect": "^1.20.2",
|
"expect": "^1.20.2",
|
||||||
"exports-loader": "^0.6.3",
|
"exports-loader": "^0.6.3",
|
||||||
"file-loader": "^0.8.5",
|
"file-loader": "^0.8.5",
|
||||||
|
"fsevents": "^1.0.14",
|
||||||
|
"identity-obj-proxy": "^3.0.0",
|
||||||
"imports-loader": "^0.6.5",
|
"imports-loader": "^0.6.5",
|
||||||
"jest-cli": "^16.0.1",
|
"jest-cli": "^16.0.1",
|
||||||
"lint-staged": "^3.1.0",
|
"lint-staged": "^3.1.0",
|
||||||
@ -64,6 +74,7 @@
|
|||||||
"postcss-import": "^8.1.2",
|
"postcss-import": "^8.1.2",
|
||||||
"postcss-loader": "^0.9.1",
|
"postcss-loader": "^0.9.1",
|
||||||
"pre-commit": "^1.1.3",
|
"pre-commit": "^1.1.3",
|
||||||
|
"react-addons-test-utils": "^15.3.2",
|
||||||
"sass-loader": "^4.0.2",
|
"sass-loader": "^4.0.2",
|
||||||
"style-loader": "^0.13.0",
|
"style-loader": "^0.13.0",
|
||||||
"stylefmt": "^4.3.1",
|
"stylefmt": "^4.3.1",
|
||||||
@ -79,6 +90,7 @@
|
|||||||
"webpack-postcss-tools": "^1.1.1"
|
"webpack-postcss-tools": "^1.1.1"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@kadira/storybook": "^1.36.0",
|
||||||
"autoprefixer": "^6.3.3",
|
"autoprefixer": "^6.3.3",
|
||||||
"bricks.js": "^1.7.0",
|
"bricks.js": "^1.7.0",
|
||||||
"dateformat": "^1.0.12",
|
"dateformat": "^1.0.12",
|
||||||
@ -112,10 +124,12 @@
|
|||||||
"react-toolbox": "^1.2.1",
|
"react-toolbox": "^1.2.1",
|
||||||
"react-waypoint": "^3.1.3",
|
"react-waypoint": "^3.1.3",
|
||||||
"redux": "^3.3.1",
|
"redux": "^3.3.1",
|
||||||
|
"redux-notifications": "^2.1.1",
|
||||||
"redux-thunk": "^1.0.3",
|
"redux-thunk": "^1.0.3",
|
||||||
"selection-position": "^1.0.0",
|
"selection-position": "^1.0.0",
|
||||||
"semaphore": "^1.0.5",
|
"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"
|
"whatwg-fetch": "^1.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,8 +1,16 @@
|
|||||||
|
import history from '../routing/history';
|
||||||
|
|
||||||
export const SWITCH_VISUAL_MODE = 'SWITCH_VISUAL_MODE';
|
export const SWITCH_VISUAL_MODE = 'SWITCH_VISUAL_MODE';
|
||||||
|
|
||||||
export function switchVisualMode(useVisualMode) {
|
export function switchVisualMode(useVisualMode) {
|
||||||
return {
|
return {
|
||||||
type: SWITCH_VISUAL_MODE,
|
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 { currentBackend } from '../backends/backend';
|
||||||
import { getIntegrationProvider } from '../integrations';
|
import { getIntegrationProvider } from '../integrations';
|
||||||
import { getMedia, selectIntegration } from '../reducers';
|
import { getMedia, selectIntegration } from '../reducers';
|
||||||
|
|
||||||
|
const { notifSend } = notifActions;
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Contant Declarations
|
* Contant Declarations
|
||||||
*/
|
*/
|
||||||
@ -35,8 +38,8 @@ export function entryLoading(collection, slug) {
|
|||||||
type: ENTRY_REQUEST,
|
type: ENTRY_REQUEST,
|
||||||
payload: {
|
payload: {
|
||||||
collection: collection.get('name'),
|
collection: collection.get('name'),
|
||||||
slug: slug
|
slug,
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -45,8 +48,8 @@ export function entryLoaded(collection, entry) {
|
|||||||
type: ENTRY_SUCCESS,
|
type: ENTRY_SUCCESS,
|
||||||
payload: {
|
payload: {
|
||||||
collection: collection.get('name'),
|
collection: collection.get('name'),
|
||||||
entry: entry
|
entry,
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -54,8 +57,8 @@ export function entriesLoading(collection) {
|
|||||||
return {
|
return {
|
||||||
type: ENTRIES_REQUEST,
|
type: ENTRIES_REQUEST,
|
||||||
payload: {
|
payload: {
|
||||||
collection: collection.get('name')
|
collection: collection.get('name'),
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -64,9 +67,9 @@ export function entriesLoaded(collection, entries, pagination) {
|
|||||||
type: ENTRIES_SUCCESS,
|
type: ENTRIES_SUCCESS,
|
||||||
payload: {
|
payload: {
|
||||||
collection: collection.get('name'),
|
collection: collection.get('name'),
|
||||||
entries: entries,
|
entries,
|
||||||
page: pagination
|
page: pagination,
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -75,7 +78,7 @@ export function entriesFailed(collection, error) {
|
|||||||
type: ENTRIES_FAILURE,
|
type: ENTRIES_FAILURE,
|
||||||
error: 'Failed to load entries',
|
error: 'Failed to load entries',
|
||||||
payload: error.toString(),
|
payload: error.toString(),
|
||||||
meta: { collection: collection.get('name') }
|
meta: { collection: collection.get('name') },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -83,9 +86,9 @@ export function entryPersisting(collection, entry) {
|
|||||||
return {
|
return {
|
||||||
type: ENTRY_PERSIST_REQUEST,
|
type: ENTRY_PERSIST_REQUEST,
|
||||||
payload: {
|
payload: {
|
||||||
collection: collection,
|
collectionName: collection.get('name'),
|
||||||
entry: entry
|
entrySlug: entry.get('slug'),
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -93,52 +96,56 @@ export function entryPersisted(collection, entry) {
|
|||||||
return {
|
return {
|
||||||
type: ENTRY_PERSIST_SUCCESS,
|
type: ENTRY_PERSIST_SUCCESS,
|
||||||
payload: {
|
payload: {
|
||||||
collection: collection,
|
collectionName: collection.get('name'),
|
||||||
entry: entry
|
entrySlug: entry.get('slug'),
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function entryPersistFail(collection, entry, error) {
|
export function entryPersistFail(collection, entry, error) {
|
||||||
return {
|
return {
|
||||||
type: ENTRIES_FAILURE,
|
type: ENTRY_PERSIST_FAILURE,
|
||||||
error: 'Failed to persist entry',
|
error: 'Failed to persist entry',
|
||||||
payload: error.toString()
|
payload: {
|
||||||
|
collectionName: collection.get('name'),
|
||||||
|
entrySlug: entry.get('slug'),
|
||||||
|
error: error.toString(),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function emmptyDraftCreated(entry) {
|
export function emmptyDraftCreated(entry) {
|
||||||
return {
|
return {
|
||||||
type: DRAFT_CREATE_EMPTY,
|
type: DRAFT_CREATE_EMPTY,
|
||||||
payload: entry
|
payload: entry,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function searchingEntries(searchTerm) {
|
export function searchingEntries(searchTerm) {
|
||||||
return {
|
return {
|
||||||
type: SEARCH_ENTRIES_REQUEST,
|
type: SEARCH_ENTRIES_REQUEST,
|
||||||
payload: { searchTerm }
|
payload: { searchTerm },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SearchSuccess(searchTerm, entries, page) {
|
export function searchSuccess(searchTerm, entries, page) {
|
||||||
return {
|
return {
|
||||||
type: SEARCH_ENTRIES_SUCCESS,
|
type: SEARCH_ENTRIES_SUCCESS,
|
||||||
payload: {
|
payload: {
|
||||||
searchTerm,
|
searchTerm,
|
||||||
entries,
|
entries,
|
||||||
page
|
page,
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SearchFailure(searchTerm, error) {
|
export function searchFailure(searchTerm, error) {
|
||||||
return {
|
return {
|
||||||
type: SEARCH_ENTRIES_FAILURE,
|
type: SEARCH_ENTRIES_FAILURE,
|
||||||
payload: {
|
payload: {
|
||||||
searchTerm,
|
searchTerm,
|
||||||
error
|
error,
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -148,20 +155,20 @@ export function SearchFailure(searchTerm, error) {
|
|||||||
export function createDraftFromEntry(entry) {
|
export function createDraftFromEntry(entry) {
|
||||||
return {
|
return {
|
||||||
type: DRAFT_CREATE_FROM_ENTRY,
|
type: DRAFT_CREATE_FROM_ENTRY,
|
||||||
payload: entry
|
payload: entry,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function discardDraft() {
|
export function discardDraft() {
|
||||||
return {
|
return {
|
||||||
type: DRAFT_DISCARD
|
type: DRAFT_DISCARD,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function changeDraft(entry) {
|
export function changeDraft(entry) {
|
||||||
return {
|
return {
|
||||||
type: DRAFT_CHANGE,
|
type: DRAFT_CHANGE,
|
||||||
payload: entry
|
payload: entry,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -180,20 +187,22 @@ export function loadEntry(entry, collection, slug) {
|
|||||||
} else {
|
} else {
|
||||||
getPromise = backend.lookupEntry(collection, slug);
|
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) {
|
export function loadEntries(collection, page = 0) {
|
||||||
return (dispatch, getState) => {
|
return (dispatch, getState) => {
|
||||||
if (collection.get('isFetching')) { return; }
|
if (collection.get('isFetching')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const state = getState();
|
const state = getState();
|
||||||
const integration = selectIntegration(state, collection.get('name'), 'listEntries');
|
const integration = selectIntegration(state, collection.get('name'), 'listEntries');
|
||||||
const provider = integration ? getIntegrationProvider(state.integrations, integration) : currentBackend(state.config);
|
const provider = integration ? getIntegrationProvider(state.integrations, integration) : currentBackend(state.config);
|
||||||
dispatch(entriesLoading(collection));
|
dispatch(entriesLoading(collection));
|
||||||
provider.listEntries(collection, page).then(
|
provider.listEntries(collection, page).then(
|
||||||
(response) => dispatch(entriesLoaded(collection, response.entries, response.pagination)),
|
response => dispatch(entriesLoaded(collection, response.entries, response.pagination)),
|
||||||
(error) => dispatch(entriesFailed(collection, error))
|
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) => {
|
return (dispatch, getState) => {
|
||||||
const state = getState();
|
const state = getState();
|
||||||
const backend = currentBackend(state.config);
|
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));
|
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));
|
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();
|
let collections = state.collections.keySeq().toArray();
|
||||||
collections = collections.filter(collection => selectIntegration(state, collection, 'search'));
|
collections = collections.filter(collection => selectIntegration(state, collection, 'search'));
|
||||||
const integration = selectIntegration(state, collections[0], 'search');
|
const integration = selectIntegration(state, collections[0], 'search');
|
||||||
if (!integration) console.warn('There isn\'t a search integration configured.');
|
if (!integration) {
|
||||||
const provider = integration ? getIntegrationProvider(state.integrations, integration) : currentBackend(state.config);
|
dispatch(searchFailure(searchTerm, 'Search integration is not configured.'));
|
||||||
|
}
|
||||||
|
const provider = integration ?
|
||||||
|
getIntegrationProvider(state.integrations, integration)
|
||||||
|
: currentBackend(state.config);
|
||||||
dispatch(searchingEntries(searchTerm));
|
dispatch(searchingEntries(searchTerm));
|
||||||
provider.search(collections, searchTerm, page).then(
|
provider.search(collections, searchTerm, page).then(
|
||||||
(response) => dispatch(SearchSuccess(searchTerm, response.entries, response.pagination)),
|
response => dispatch(searchSuccess(searchTerm, response.entries, response.pagination)),
|
||||||
(error) => dispatch(SearchFailure(searchTerm, error))
|
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 pluralize from 'pluralize';
|
||||||
import { IndexLink } from 'react-router';
|
import { IndexLink } from 'react-router';
|
||||||
import { Menu, MenuItem } from 'react-toolbox';
|
import { Menu, MenuItem } from 'react-toolbox';
|
||||||
@ -8,11 +9,20 @@ import styles from './AppHeader.css';
|
|||||||
|
|
||||||
export default class AppHeader extends React.Component {
|
export default class AppHeader extends React.Component {
|
||||||
|
|
||||||
state = {
|
static propTypes = {
|
||||||
createMenuActive: false
|
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;
|
const { onCreateEntryClick } = this.props;
|
||||||
if (onCreateEntryClick) {
|
if (onCreateEntryClick) {
|
||||||
onCreateEntryClick(collectionName);
|
onCreateEntryClick(collectionName);
|
||||||
@ -21,13 +31,13 @@ export default class AppHeader extends React.Component {
|
|||||||
|
|
||||||
handleCreateButtonClick = () => {
|
handleCreateButtonClick = () => {
|
||||||
this.setState({
|
this.setState({
|
||||||
createMenuActive: true
|
createMenuActive: true,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
handleCreateMenuHide = () => {
|
handleCreateMenuHide = () => {
|
||||||
this.setState({
|
this.setState({
|
||||||
createMenuActive: false
|
createMenuActive: false,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -37,7 +47,7 @@ export default class AppHeader extends React.Component {
|
|||||||
commands,
|
commands,
|
||||||
defaultCommands,
|
defaultCommands,
|
||||||
runCommand,
|
runCommand,
|
||||||
toggleNavDrawer
|
toggleNavDrawer,
|
||||||
} = this.props;
|
} = this.props;
|
||||||
const { createMenuActive } = this.state;
|
const { createMenuActive } = this.state;
|
||||||
|
|
||||||
@ -59,7 +69,6 @@ export default class AppHeader extends React.Component {
|
|||||||
defaultCommands={defaultCommands}
|
defaultCommands={defaultCommands}
|
||||||
runCommand={runCommand}
|
runCommand={runCommand}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Menu
|
<Menu
|
||||||
active={createMenuActive}
|
active={createMenuActive}
|
||||||
position="topRight"
|
position="topRight"
|
||||||
@ -70,7 +79,7 @@ export default class AppHeader extends React.Component {
|
|||||||
<MenuItem
|
<MenuItem
|
||||||
key={collection.get('name')}
|
key={collection.get('name')}
|
||||||
value={collection.get('name')}
|
value={collection.get('name')}
|
||||||
onClick={this.handleCreatePostClick.bind(this, collection.get('name'))}
|
onClick={this.handleCreatePostClick.bind(this, collection.get('name'))} // eslint-disable-line
|
||||||
caption={pluralize(collection.get('label'), 1)}
|
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>"`;
|
@ -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 {
|
.frame {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100vh;
|
height: 100%;
|
||||||
border: none;
|
border: none;
|
||||||
background: #fff;
|
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';
|
@ -2,10 +2,13 @@
|
|||||||
--defaultColor: #333;
|
--defaultColor: #333;
|
||||||
--defaultColorLight: #eee;
|
--defaultColorLight: #eee;
|
||||||
--backgroundColor: #fff;
|
--backgroundColor: #fff;
|
||||||
--shadowColor: rgba(0, 0, 0, 0.117647);
|
--shadowColor: rgba(0, 0, 0, .25);
|
||||||
|
--infoColor: #69c;
|
||||||
--successColor: #1c7;
|
--successColor: #1c7;
|
||||||
--warningColor: #fa0;
|
--warningColor: #fa0;
|
||||||
--errorColor: #f52;
|
--errorColor: #f52;
|
||||||
|
--borderRadius: 2px;
|
||||||
|
--topmostZindex: 99999;
|
||||||
}
|
}
|
||||||
|
|
||||||
.base {
|
.base {
|
||||||
@ -13,14 +16,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.container {
|
.container {
|
||||||
color: var(--defaultColor);
|
|
||||||
background-color: var(--backgroundColor);
|
background-color: var(--backgroundColor);
|
||||||
|
color: var(--defaultColor);
|
||||||
}
|
}
|
||||||
|
|
||||||
.rounded {
|
.rounded {
|
||||||
border-radius: 2px;
|
border-radius: var(--borderRadius);
|
||||||
}
|
}
|
||||||
|
|
||||||
.depth {
|
.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 {
|
:root {
|
||||||
composes: base container rounded depth;
|
--iconSize: 30px;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.hidden {
|
.root {
|
||||||
opacity: 0;
|
composes: base container rounded depth from '../theme.css';
|
||||||
|
overflow: hidden;
|
||||||
|
margin: 10px;
|
||||||
|
padding: 10px 10px 15px;
|
||||||
|
color: var(--defaultColorLight);
|
||||||
}
|
}
|
||||||
|
|
||||||
.icon {
|
.icon {
|
||||||
position: absolute;
|
position: relative;
|
||||||
top: calc(50% - 15px);
|
top: .15em;
|
||||||
left: 15px;
|
margin-right: .25em;
|
||||||
font-size: 30px;
|
font-size: var(--iconSize);
|
||||||
|
line-height: var(--iconSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
.info {
|
||||||
|
composes: root;
|
||||||
|
background-color: var(--infoColor);
|
||||||
}
|
}
|
||||||
|
|
||||||
.success {
|
.success {
|
||||||
|
composes: root;
|
||||||
background-color: var(--successColor);
|
background-color: var(--successColor);
|
||||||
}
|
}
|
||||||
|
|
||||||
.warning {
|
.warning {
|
||||||
|
composes: root;
|
||||||
background-color: var(--warningColor);
|
background-color: var(--warningColor);
|
||||||
}
|
}
|
||||||
|
|
||||||
.error {
|
.danger {
|
||||||
|
composes: root;
|
||||||
background-color: var(--errorColor);
|
background-color: var(--errorColor);
|
||||||
}
|
}
|
||||||
|
@ -2,69 +2,23 @@ import React, { PropTypes } from 'react';
|
|||||||
import { Icon } from '../index';
|
import { Icon } from '../index';
|
||||||
import styles from './Toast.css';
|
import styles from './Toast.css';
|
||||||
|
|
||||||
export default class Toast extends React.Component {
|
const icons = {
|
||||||
|
info: 'info',
|
||||||
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',
|
success: 'check',
|
||||||
warning: 'attention',
|
warning: 'attention',
|
||||||
error: 'alert'
|
danger: '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);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
export default function Toast({ kind, message }) {
|
||||||
return (
|
return (
|
||||||
<div className={classes.join(' ')} style={style}>{icon}{children}</div>
|
<div className={styles[kind]}>
|
||||||
|
<Icon type={icons[kind]} className={styles.icon} />
|
||||||
|
{message}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Toast.propTypes = {
|
Toast.propTypes = {
|
||||||
style: PropTypes.object,
|
kind: PropTypes.oneOf(['info', 'success', 'warning', 'danger']).isRequired,
|
||||||
type: PropTypes.oneOf(['success', 'warning', 'error']).isRequired,
|
message: PropTypes.string,
|
||||||
className: PropTypes.string,
|
|
||||||
show: PropTypes.bool,
|
|
||||||
children: PropTypes.node
|
|
||||||
};
|
};
|
||||||
|
@ -16,10 +16,6 @@ class MarkdownControl extends React.Component {
|
|||||||
value: PropTypes.node,
|
value: PropTypes.node,
|
||||||
};
|
};
|
||||||
|
|
||||||
static contextTypes = {
|
|
||||||
plugins: PropTypes.object,
|
|
||||||
};
|
|
||||||
|
|
||||||
componentWillMount() {
|
componentWillMount() {
|
||||||
this.useRawEditor();
|
this.useRawEditor();
|
||||||
processEditorPlugins(registry.getEditorComponents());
|
processEditorPlugins(registry.getEditorComponents());
|
||||||
@ -33,7 +29,7 @@ class MarkdownControl extends React.Component {
|
|||||||
this.props.switchVisualMode(false);
|
this.props.switchVisualMode(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
renderEditor() {
|
render() {
|
||||||
const { editor, onChange, onAddMedia, getMedia, value } = this.props;
|
const { editor, onChange, onAddMedia, getMedia, value } = this.props;
|
||||||
if (editor.get('useVisualMode')) {
|
if (editor.get('useVisualMode')) {
|
||||||
return (
|
return (
|
||||||
@ -62,15 +58,6 @@ class MarkdownControl extends React.Component {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
|
|
||||||
{this.renderEditor()}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default connect(
|
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 React, { PropTypes } from 'react';
|
||||||
import { Editor, Plain, Mark } from 'slate';
|
import { Editor, Plain, Mark } from 'slate';
|
||||||
import Prism from 'prismjs';
|
import Prism from 'prismjs';
|
||||||
|
import PluginDropImages from 'slate-drop-or-paste-images';
|
||||||
|
import MediaProxy from '../../../../valueObjects/MediaProxy';
|
||||||
import marks from './prismMarkdown';
|
import marks from './prismMarkdown';
|
||||||
|
import styles from './index.css';
|
||||||
|
|
||||||
Prism.languages.markdown = Prism.languages.extend('markup', {});
|
Prism.languages.markdown = Prism.languages.extend('markup', {});
|
||||||
Prism.languages.insertBefore('markdown', 'prolog', marks);
|
Prism.languages.insertBefore('markdown', 'prolog', marks);
|
||||||
@ -41,7 +44,6 @@ function renderDecorations(text, block) {
|
|||||||
return characters.asImmutable();
|
return characters.asImmutable();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const SCHEMA = {
|
const SCHEMA = {
|
||||||
rules: [
|
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) {
|
constructor(props) {
|
||||||
super(props);
|
super(props);
|
||||||
const content = props.value ? Plain.deserialize(props.value) : Plain.deserialize('');
|
const content = props.value ? Plain.deserialize(props.value) : Plain.deserialize('');
|
||||||
@ -78,12 +88,24 @@ class RawEditor extends React.Component {
|
|||||||
this.state = {
|
this.state = {
|
||||||
state: content
|
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.
|
* Slate keeps track of selections, scroll position etc.
|
||||||
* So, onChange gets dispatched on every interaction (click, arrows, everything...)
|
* 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
|
* content changes
|
||||||
*/
|
*/
|
||||||
handleChange = state => {
|
handleChange = state => {
|
||||||
@ -98,20 +120,14 @@ class RawEditor extends React.Component {
|
|||||||
render() {
|
render() {
|
||||||
return (
|
return (
|
||||||
<Editor
|
<Editor
|
||||||
|
className={styles.root}
|
||||||
placeholder={'Enter some rich text...'}
|
placeholder={'Enter some rich text...'}
|
||||||
state={this.state.state}
|
state={this.state.state}
|
||||||
schema={SCHEMA}
|
schema={SCHEMA}
|
||||||
onChange={this.handleChange}
|
onChange={this.handleChange}
|
||||||
onDocumentChange={this.handleDocumentChange}
|
onDocumentChange={this.handleDocumentChange}
|
||||||
renderDecorations={this.renderDecorations}
|
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 React, { Component, PropTypes } from 'react';
|
||||||
import Portal from 'react-portal';
|
import withPortalAtCursorPosition from './withPortalAtCursorPosition';
|
||||||
import { Icon } from '../../../UI';
|
import { Icon } from '../../../UI';
|
||||||
import MediaProxy from '../../../../valueObjects/MediaProxy';
|
import MediaProxy from '../../../../valueObjects/MediaProxy';
|
||||||
import styles from './BlockTypesMenu.css';
|
import styles from './BlockTypesMenu.css';
|
||||||
|
|
||||||
export default class BlockTypesMenu extends Component {
|
class BlockTypesMenu extends Component {
|
||||||
constructor(props) {
|
|
||||||
super(props);
|
|
||||||
|
|
||||||
this.state = {
|
static propTypes = {
|
||||||
expanded: false,
|
plugins: PropTypes.array.isRequired,
|
||||||
menu: null
|
onClickBlock: PropTypes.func.isRequired,
|
||||||
|
onClickPlugin: PropTypes.func.isRequired,
|
||||||
|
onClickImage: PropTypes.func.isRequired
|
||||||
};
|
};
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
state = {
|
||||||
* On update, update the menu.
|
expanded: false
|
||||||
*/
|
};
|
||||||
componentDidMount() {
|
|
||||||
this.updateMenuPosition();
|
|
||||||
}
|
|
||||||
|
|
||||||
componentWillUpdate() {
|
componentWillUpdate() {
|
||||||
if (this.state.expanded) {
|
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 = () => {
|
toggleMenu = () => {
|
||||||
this.setState({ expanded: !this.state.expanded });
|
this.setState({ expanded: !this.state.expanded });
|
||||||
};
|
};
|
||||||
@ -53,7 +34,7 @@ export default class BlockTypesMenu extends Component {
|
|||||||
handlePluginClick = (e, plugin) => {
|
handlePluginClick = (e, plugin) => {
|
||||||
const data = {};
|
const data = {};
|
||||||
plugin.fields.forEach(field => {
|
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);
|
this.props.onClickPlugin(plugin.id, data);
|
||||||
};
|
};
|
||||||
@ -87,14 +68,14 @@ export default class BlockTypesMenu extends Component {
|
|||||||
renderBlockTypeButton = (type, icon) => {
|
renderBlockTypeButton = (type, icon) => {
|
||||||
const onClick = e => this.handleBlockTypeClick(e, type);
|
const onClick = e => this.handleBlockTypeClick(e, type);
|
||||||
return (
|
return (
|
||||||
<Icon key={type} type={icon} onClick={onClick} className={styles.icon} />
|
<Icon key={type} type={icon} onClick={onClick} className={styles.icon}/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
renderPluginButton = plugin => {
|
renderPluginButton = plugin => {
|
||||||
const onClick = e => this.handlePluginClick(e, plugin);
|
const onClick = e => this.handlePluginClick(e, plugin);
|
||||||
return (
|
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}>
|
<div className={styles.menu}>
|
||||||
{this.renderBlockTypeButton('hr', 'dot-3')}
|
{this.renderBlockTypeButton('hr', 'dot-3')}
|
||||||
{plugins.map(plugin => this.renderPluginButton(plugin))}
|
{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
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
accept="image/*"
|
accept="image/*"
|
||||||
onChange={this.handleFileUploadChange}
|
onChange={this.handleFileUploadChange}
|
||||||
className={styles.input}
|
className={styles.input}
|
||||||
ref={(el) => this._fileInput = el}
|
ref={el => {
|
||||||
|
this._fileInput = el;
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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() {
|
render() {
|
||||||
const { isOpen } = this.props;
|
|
||||||
return (
|
return (
|
||||||
<Portal isOpened={isOpen} onOpen={this.handleOpen}>
|
|
||||||
<div className={styles.root}>
|
<div className={styles.root}>
|
||||||
<Icon type="plus-squared" className={styles.button} onClick={this.toggleMenu} />
|
<Icon type="plus-squared" className={styles.button} onClick={this.toggleMenu}/>
|
||||||
{this.renderMenu()}
|
{this.renderMenu()}
|
||||||
</div>
|
</div>
|
||||||
</Portal>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
BlockTypesMenu.propTypes = {
|
export default withPortalAtCursorPosition(BlockTypesMenu);
|
||||||
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
|
|
||||||
};
|
|
||||||
|
@ -1,36 +1,17 @@
|
|||||||
import React, { Component, PropTypes } from 'react';
|
import React, { Component, PropTypes } from 'react';
|
||||||
import Portal from 'react-portal';
|
import withPortalAtCursorPosition from './withPortalAtCursorPosition';
|
||||||
import { Icon } from '../../../UI';
|
import { Icon } from '../../../UI';
|
||||||
import styles from './StylesMenu.css';
|
import styles from './StylesMenu.css';
|
||||||
|
|
||||||
export default class StylesMenu extends Component {
|
class StylesMenu extends Component {
|
||||||
constructor(props) {
|
|
||||||
super(props);
|
|
||||||
|
|
||||||
this.state = {
|
static propTypes = {
|
||||||
menu: null
|
marks: PropTypes.object.isRequired,
|
||||||
};
|
blocks: PropTypes.object.isRequired,
|
||||||
}
|
inlines: PropTypes.object.isRequired,
|
||||||
|
onClickBlock: PropTypes.func.isRequired,
|
||||||
/**
|
onClickMark: PropTypes.func.isRequired,
|
||||||
* On update, update the menu.
|
onClickInline: PropTypes.func.isRequired
|
||||||
*/
|
|
||||||
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`;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -46,10 +27,10 @@ export default class StylesMenu extends Component {
|
|||||||
return blocks.some(node => node.type == type);
|
return blocks.some(node => node.type == type);
|
||||||
};
|
};
|
||||||
|
|
||||||
hasLinks(type) {
|
hasLinks = type => {
|
||||||
const { inlines } = this.props;
|
const { inlines } = this.props;
|
||||||
return inlines.some(inline => inline.type == 'link');
|
return inlines.some(inline => inline.type == 'link');
|
||||||
}
|
};
|
||||||
|
|
||||||
handleMarkClick = (e, type) => {
|
handleMarkClick = (e, type) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@ -99,17 +80,8 @@ export default class StylesMenu extends Component {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* When the portal opens, cache the menu element.
|
|
||||||
*/
|
|
||||||
handleOpen = portal => {
|
|
||||||
this.setState({ menu: portal.firstChild });
|
|
||||||
};
|
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { isOpen } = this.props;
|
|
||||||
return (
|
return (
|
||||||
<Portal isOpened={isOpen} onOpen={this.handleOpen}>
|
|
||||||
<div className={`${styles.menu} ${styles.hoverMenu}`}>
|
<div className={`${styles.menu} ${styles.hoverMenu}`}>
|
||||||
{this.renderMarkButton('BOLD', 'bold')}
|
{this.renderMarkButton('BOLD', 'bold')}
|
||||||
{this.renderMarkButton('ITALIC', 'italic')}
|
{this.renderMarkButton('ITALIC', 'italic')}
|
||||||
@ -120,21 +92,8 @@ export default class StylesMenu extends Component {
|
|||||||
{this.renderBlockButton('blockquote', 'quote-left')}
|
{this.renderBlockButton('blockquote', 'quote-left')}
|
||||||
{this.renderBlockButton('unordered_list', 'list-bullet', 'list_item')}
|
{this.renderBlockButton('unordered_list', 'list-bullet', 'list_item')}
|
||||||
</div>
|
</div>
|
||||||
</Portal>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
StylesMenu.propTypes = {
|
export default withPortalAtCursorPosition(StylesMenu);
|
||||||
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
|
|
||||||
};
|
|
||||||
|
@ -1,19 +1,27 @@
|
|||||||
import React, { PropTypes } from 'react';
|
import React, { PropTypes } from 'react';
|
||||||
import _ from 'lodash';
|
import _ from 'lodash';
|
||||||
import { Editor, Raw } from 'slate';
|
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 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 { DEFAULT_NODE, SCHEMA } from './schema';
|
||||||
import { getNodes, getSyntaxes, getPlugins } from '../../richText';
|
import { getNodes, getSyntaxes, getPlugins } from '../../richText';
|
||||||
import StylesMenu from './StylesMenu';
|
import StylesMenu from './StylesMenu';
|
||||||
import BlockTypesMenu from './BlockTypesMenu';
|
import BlockTypesMenu from './BlockTypesMenu';
|
||||||
//import styles from './index.css';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Slate Render Configuration
|
* 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) {
|
constructor(props) {
|
||||||
super(props);
|
super(props);
|
||||||
|
|
||||||
@ -23,25 +31,10 @@ class VisualEditor extends React.Component {
|
|||||||
SCHEMA.nodes = _.merge(SCHEMA.nodes, getNodes());
|
SCHEMA.nodes = _.merge(SCHEMA.nodes, getNodes());
|
||||||
|
|
||||||
this.blockEdit = false;
|
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;
|
let rawJson;
|
||||||
if (props.value !== undefined) {
|
if (props.value !== undefined) {
|
||||||
const content = this.markdown.toContent(props.value);
|
const content = this.markdown.toContent(props.value);
|
||||||
console.log('md: %o', content);
|
|
||||||
rawJson = SlateUtils.encode(content, null, ['mediaproxy'].concat(getPlugins().map(plugin => plugin.id)));
|
rawJson = SlateUtils.encode(content, null, ['mediaproxy'].concat(getPlugins().map(plugin => plugin.id)));
|
||||||
} else {
|
} else {
|
||||||
rawJson = emptyParagraphBlock;
|
rawJson = emptyParagraphBlock;
|
||||||
@ -50,8 +43,16 @@ class VisualEditor extends React.Component {
|
|||||||
state: Raw.deserialize(rawJson, { terse: true })
|
state: Raw.deserialize(rawJson, { terse: true })
|
||||||
};
|
};
|
||||||
|
|
||||||
this.calculateHoverMenuPosition = _.throttle(this.calculateHoverMenuPosition.bind(this), 30);
|
this.plugins = [
|
||||||
this.calculateBlockMenuPosition = _.throttle(this.calculateBlockMenuPosition.bind(this), 100);
|
PluginDropImages({
|
||||||
|
applyTransform: (transform, file) => {
|
||||||
|
const mediaProxy = new MediaProxy(file.name, file);
|
||||||
|
props.onAddMedia(mediaProxy);
|
||||||
|
return transform
|
||||||
|
.insertBlock(mediaproxyBlock(mediaProxy));
|
||||||
|
}
|
||||||
|
})
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
getMedia = src => {
|
getMedia = src => {
|
||||||
@ -61,15 +62,14 @@ class VisualEditor extends React.Component {
|
|||||||
/**
|
/**
|
||||||
* Slate keeps track of selections, scroll position etc.
|
* Slate keeps track of selections, scroll position etc.
|
||||||
* So, onChange gets dispatched on every interaction (click, arrows, everything...)
|
* 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
|
* content changes
|
||||||
*/
|
*/
|
||||||
handleChange = state => {
|
handleChange = state => {
|
||||||
if (this.blockEdit) {
|
if (this.blockEdit) {
|
||||||
this.blockEdit = false;
|
this.blockEdit = false;
|
||||||
} else {
|
} else {
|
||||||
this.calculateHoverMenuPosition();
|
this.setState({ state });
|
||||||
this.setState({ state }, this.calculateBlockMenuPosition);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -79,32 +79,6 @@ class VisualEditor extends React.Component {
|
|||||||
this.props.onChange(this.markdown.toText(content));
|
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
|
* Toggle marks / blocks when button is clicked
|
||||||
*/
|
*/
|
||||||
@ -186,7 +160,7 @@ class VisualEditor extends React.Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
else {
|
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
|
state = state
|
||||||
.transform()
|
.transform()
|
||||||
.wrapInline({
|
.wrapInline({
|
||||||
@ -238,14 +212,7 @@ class VisualEditor extends React.Component {
|
|||||||
|
|
||||||
state = state
|
state = state
|
||||||
.transform()
|
.transform()
|
||||||
.insertInline({
|
.insertBlock(mediaproxyBlock(mediaProxy))
|
||||||
type: 'mediaproxy',
|
|
||||||
isVoid: true,
|
|
||||||
data: { src: mediaProxy.public_path }
|
|
||||||
})
|
|
||||||
.collapseToEnd()
|
|
||||||
.insertBlock(DEFAULT_NODE)
|
|
||||||
.focus()
|
|
||||||
.apply();
|
.apply();
|
||||||
|
|
||||||
this.setState({ state });
|
this.setState({ state });
|
||||||
@ -264,7 +231,7 @@ class VisualEditor extends React.Component {
|
|||||||
.apply({
|
.apply({
|
||||||
snapshot: false
|
snapshot: false
|
||||||
});
|
});
|
||||||
this.setState({ state:normalized });
|
this.setState({ state: normalized });
|
||||||
};
|
};
|
||||||
|
|
||||||
handleKeyDown = evt => {
|
handleKeyDown = evt => {
|
||||||
@ -273,7 +240,7 @@ class VisualEditor extends React.Component {
|
|||||||
let { state } = this.state;
|
let { state } = this.state;
|
||||||
state = state
|
state = state
|
||||||
.transform()
|
.transform()
|
||||||
.insertText(' \n')
|
.insertText('\n')
|
||||||
.apply();
|
.apply();
|
||||||
|
|
||||||
this.setState({ state });
|
this.setState({ state });
|
||||||
@ -288,7 +255,6 @@ class VisualEditor extends React.Component {
|
|||||||
<BlockTypesMenu
|
<BlockTypesMenu
|
||||||
isOpen={isOpen}
|
isOpen={isOpen}
|
||||||
plugins={getPlugins()}
|
plugins={getPlugins()}
|
||||||
position={this.menuPositions.blockTypesMenu}
|
|
||||||
onClickBlock={this.handleBlockTypeClick}
|
onClickBlock={this.handleBlockTypeClick}
|
||||||
onClickPlugin={this.handlePluginClick}
|
onClickPlugin={this.handlePluginClick}
|
||||||
onClickImage={this.handleImageClick}
|
onClickImage={this.handleImageClick}
|
||||||
@ -303,7 +269,6 @@ class VisualEditor extends React.Component {
|
|||||||
return (
|
return (
|
||||||
<StylesMenu
|
<StylesMenu
|
||||||
isOpen={isOpen}
|
isOpen={isOpen}
|
||||||
position={this.menuPositions.stylesMenu}
|
|
||||||
marks={this.state.state.marks}
|
marks={this.state.state.marks}
|
||||||
blocks={this.state.state.blocks}
|
blocks={this.state.state.blocks}
|
||||||
inlines={this.state.state.inlines}
|
inlines={this.state.state.inlines}
|
||||||
@ -323,6 +288,7 @@ class VisualEditor extends React.Component {
|
|||||||
placeholder={'Enter some rich text...'}
|
placeholder={'Enter some rich text...'}
|
||||||
state={this.state.state}
|
state={this.state.state}
|
||||||
schema={SCHEMA}
|
schema={SCHEMA}
|
||||||
|
plugins={this.plugins}
|
||||||
onChange={this.handleChange}
|
onChange={this.handleChange}
|
||||||
onKeyDown={this.handleKeyDown}
|
onKeyDown={this.handleKeyDown}
|
||||||
onDocumentChange={this.handleDocumentChange}
|
onDocumentChange={this.handleDocumentChange}
|
||||||
@ -331,12 +297,3 @@ class VisualEditor extends React.Component {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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 = {
|
export const emptyParagraphBlock = {
|
||||||
nodes: [
|
nodes: [
|
||||||
{ kind: 'block',
|
{
|
||||||
|
kind: 'block',
|
||||||
type: 'paragraph',
|
type: 'paragraph',
|
||||||
nodes: [{
|
nodes: [{
|
||||||
kind: 'text',
|
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 React, { PropTypes } from 'react';
|
||||||
import MarkupIt from 'markup-it';
|
|
||||||
import { getSyntaxes } from './richText';
|
import { getSyntaxes } from './richText';
|
||||||
|
import MarkupItReactRenderer from '../MarkupItReactRenderer/index';
|
||||||
|
|
||||||
export default class MarkdownPreview extends React.Component {
|
const MarkdownPreview = ({ value, getMedia }) => {
|
||||||
|
if (value == null) {
|
||||||
constructor(props) {
|
return null;
|
||||||
super(props);
|
|
||||||
|
|
||||||
const { markdown, html } = getSyntaxes();
|
|
||||||
this.markdown = new MarkupIt(markdown);
|
|
||||||
this.html = new MarkupIt(html);
|
|
||||||
}
|
}
|
||||||
render() {
|
|
||||||
const { value } = this.props;
|
|
||||||
if (value == null) { return null; }
|
|
||||||
const content = this.markdown.toContent(value);
|
|
||||||
const contentHtml = { __html: this.html.toText(content) };
|
|
||||||
|
|
||||||
|
const schema = {
|
||||||
|
'mediaproxy': ({ token }) => ( // eslint-disable-line
|
||||||
|
<img
|
||||||
|
src={getMedia(token.getIn(['data', 'src']))}
|
||||||
|
alt={token.getIn(['data', 'alt'])}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
const { markdown } = getSyntaxes();
|
||||||
return (
|
return (
|
||||||
<div dangerouslySetInnerHTML={contentHtml} />
|
<MarkupItReactRenderer
|
||||||
|
value={value}
|
||||||
|
syntax={markdown}
|
||||||
|
schema={schema}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
}
|
|
||||||
|
|
||||||
MarkdownPreview.propTypes = {
|
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([]);
|
let processedPlugins = List([]);
|
||||||
|
|
||||||
|
|
||||||
const nodes = {};
|
const nodes = {};
|
||||||
let augmentedMarkdownSyntax = markdownSyntax;
|
let augmentedMarkdownSyntax = markdownSyntax;
|
||||||
let augmentedHTMLSyntax = htmlSyntax;
|
let augmentedHTMLSyntax = htmlSyntax;
|
||||||
@ -103,7 +102,7 @@ function processMediaProxyPlugins(getMedia) {
|
|||||||
const className = isFocused ? 'active' : null;
|
const className = isFocused ? 'active' : null;
|
||||||
const src = node.data.get('src');
|
const src = node.data.get('src');
|
||||||
return (
|
return (
|
||||||
<img {...props.attributes} src={getMedia(src)} className={className} />
|
<img {...props.attributes} src={getMedia(src)} className={className}/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
augmentedMarkdownSyntax = augmentedMarkdownSyntax.addInlineRules(mediaProxyMarkdownRule);
|
augmentedMarkdownSyntax = augmentedMarkdownSyntax.addInlineRules(mediaProxyMarkdownRule);
|
||||||
@ -111,9 +110,11 @@ function processMediaProxyPlugins(getMedia) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getPlugins() {
|
function getPlugins() {
|
||||||
return processedPlugins.map(plugin => (
|
return processedPlugins.map(plugin => ({
|
||||||
{ id: plugin.id, icon: plugin.icon, fields: plugin.fields }
|
id: plugin.id,
|
||||||
)).toArray();
|
icon: plugin.icon,
|
||||||
|
fields: plugin.fields
|
||||||
|
})).toArray();
|
||||||
}
|
}
|
||||||
|
|
||||||
function getNodes() {
|
function getNodes() {
|
||||||
@ -124,7 +125,7 @@ function getSyntaxes(getMedia) {
|
|||||||
if (getMedia) {
|
if (getMedia) {
|
||||||
processMediaProxyPlugins(getMedia);
|
processMediaProxyPlugins(getMedia);
|
||||||
}
|
}
|
||||||
return { markdown: augmentedMarkdownSyntax, html:augmentedHTMLSyntax };
|
return { markdown: augmentedMarkdownSyntax, html: augmentedHTMLSyntax };
|
||||||
}
|
}
|
||||||
|
|
||||||
export { processEditorPlugins, getNodes, getSyntaxes, getPlugins };
|
export { processEditorPlugins, getNodes, getSyntaxes, getPlugins };
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { storiesOf, action } from '@kadira/storybook';
|
import { storiesOf, action } from '@kadira/storybook';
|
||||||
|
|
||||||
import FindBar from '../UI/FindBar/FindBar';
|
import FindBar from '../FindBar/FindBar';
|
||||||
|
|
||||||
const CREATE_COLLECTION = 'CREATE_COLLECTION';
|
const CREATE_COLLECTION = 'CREATE_COLLECTION';
|
||||||
const CREATE_POST = 'CREATE_POST';
|
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 React from 'react';
|
||||||
import { Toast } from '../UI';
|
|
||||||
import { storiesOf } from '@kadira/storybook';
|
import { storiesOf } from '@kadira/storybook';
|
||||||
|
import { Toast } from '../UI';
|
||||||
|
|
||||||
|
const containerStyle = {
|
||||||
|
position: 'fixed',
|
||||||
|
top: 0,
|
||||||
|
right: 0,
|
||||||
|
width: 360,
|
||||||
|
height: '100%',
|
||||||
|
};
|
||||||
|
|
||||||
storiesOf('Toast', module)
|
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', () => (
|
.add('Success', () => (
|
||||||
<div>
|
<div style={containerStyle}>
|
||||||
<Toast type='success' show>A Toast Message</Toast>
|
<Toast kind="success" message="A Toast Message" />
|
||||||
</div>
|
</div>
|
||||||
)).add('Waring', () => (
|
))
|
||||||
<div>
|
.add('Waring', () => (
|
||||||
<Toast type='warning' show>A Toast Message</Toast>
|
<div style={containerStyle}>
|
||||||
|
<Toast kind="warning" message="A Toast Message" />
|
||||||
</div>
|
</div>
|
||||||
)).add('Error', () => (
|
))
|
||||||
<div>
|
.add('Error', () => (
|
||||||
<Toast type='error' show>A Toast Message</Toast>
|
<div style={containerStyle}>
|
||||||
|
<Toast kind="danger" message="A Toast Message" />
|
||||||
</div>
|
</div>
|
||||||
));
|
));
|
||||||
|
@ -2,3 +2,5 @@ import './Card';
|
|||||||
import './Icon';
|
import './Icon';
|
||||||
import './Toast';
|
import './Toast';
|
||||||
import './FindBar';
|
import './FindBar';
|
||||||
|
import './MarkupItReactRenderer';
|
||||||
|
import './ScrollSync';
|
||||||
|
@ -1,20 +1,30 @@
|
|||||||
|
@import '../components/UI/theme.css';
|
||||||
|
|
||||||
.layout .navDrawer .drawerContent {
|
.layout .navDrawer .drawerContent {
|
||||||
padding-top: 54px;
|
padding-top: 54px;
|
||||||
|
max-width: 240px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav {
|
.nav {
|
||||||
display: block;
|
display: block;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
|
|
||||||
& .heading {
|
& .heading {
|
||||||
border: none;
|
border: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.main {
|
.main {
|
||||||
padding-top: 54px;
|
padding-top: 54px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.navDrawer {
|
.notifsContainer {
|
||||||
max-width: 240px !important;
|
position: fixed;
|
||||||
& .drawerContent {
|
top: 60px;
|
||||||
max-width: 240px !important;
|
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 pluralize from 'pluralize';
|
||||||
import { connect } from 'react-redux';
|
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 { loadConfig } from '../actions/config';
|
||||||
import { loginUser } from '../actions/auth';
|
import { loginUser } from '../actions/auth';
|
||||||
import { currentBackend } from '../backends/backend';
|
import { currentBackend } from '../backends/backend';
|
||||||
@ -11,39 +15,45 @@ import {
|
|||||||
HELP,
|
HELP,
|
||||||
runCommand,
|
runCommand,
|
||||||
navigateToCollection,
|
navigateToCollection,
|
||||||
createNewEntryInCollection
|
createNewEntryInCollection,
|
||||||
} from '../actions/findbar';
|
} from '../actions/findbar';
|
||||||
import AppHeader from '../components/AppHeader/AppHeader';
|
import AppHeader from '../components/AppHeader/AppHeader';
|
||||||
import { Loader } from '../components/UI/index';
|
import { Loader, Toast } from '../components/UI/index';
|
||||||
import styles from './App.css';
|
import styles from './App.css';
|
||||||
|
|
||||||
class App extends React.Component {
|
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 = {
|
state = {
|
||||||
navDrawerIsVisible: true
|
navDrawerIsVisible: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
componentDidMount() {
|
componentDidMount() {
|
||||||
this.props.dispatch(loadConfig());
|
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) {
|
handleLogin(credentials) {
|
||||||
this.props.dispatch(loginUser(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><h1>Waiting for backend...</h1></div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <div>
|
return (
|
||||||
{React.createElement(backend.authComponent(), {
|
<div>
|
||||||
|
{
|
||||||
|
React.createElement(backend.authComponent(), {
|
||||||
onLogin: this.handleLogin.bind(this),
|
onLogin: this.handleLogin.bind(this),
|
||||||
error: auth && auth.get('error'),
|
error: auth && auth.get('error'),
|
||||||
isFetching: auth && auth.get('isFetching')
|
isFetching: auth && auth.get('isFetching'),
|
||||||
})}
|
})
|
||||||
</div>;
|
}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
generateFindBarCommands() {
|
generateFindBarCommands() {
|
||||||
@ -70,22 +84,22 @@ class App extends React.Component {
|
|||||||
const commands = [];
|
const commands = [];
|
||||||
const defaultCommands = [];
|
const defaultCommands = [];
|
||||||
|
|
||||||
this.props.collections.forEach(collection => {
|
this.props.collections.forEach((collection) => {
|
||||||
commands.push({
|
commands.push({
|
||||||
id: `show_${collection.get('name')}`,
|
id: `show_${ collection.get('name') }`,
|
||||||
pattern: `Show ${pluralize(collection.get('label'))}`,
|
pattern: `Show ${ pluralize(collection.get('label')) }`,
|
||||||
type: SHOW_COLLECTION,
|
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) {
|
if (collection.get('create') === true) {
|
||||||
commands.push({
|
commands.push({
|
||||||
id: `create_${collection.get('name')}`,
|
id: `create_${ collection.get('name') }`,
|
||||||
pattern: `Create new ${pluralize(collection.get('label'), 1)}(:itemName as ${pluralize(collection.get('label'), 1)} Name)`,
|
pattern: `Create new ${ pluralize(collection.get('label'), 1) }(:itemName as ${ pluralize(collection.get('label'), 1) } Name)`,
|
||||||
type: CREATE_COLLECTION,
|
type: CREATE_COLLECTION,
|
||||||
payload: { collectionName: collection.get('name') }
|
payload: { collectionName: collection.get('name') },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -98,7 +112,7 @@ class App extends React.Component {
|
|||||||
|
|
||||||
toggleNavDrawer = () => {
|
toggleNavDrawer = () => {
|
||||||
this.setState({
|
this.setState({
|
||||||
navDrawerIsVisible: !this.state.navDrawerIsVisible
|
navDrawerIsVisible: !this.state.navDrawerIsVisible,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -111,7 +125,7 @@ class App extends React.Component {
|
|||||||
collections,
|
collections,
|
||||||
runCommand,
|
runCommand,
|
||||||
navigateToCollection,
|
navigateToCollection,
|
||||||
createNewEntryInCollection
|
createNewEntryInCollection,
|
||||||
} = this.props;
|
} = this.props;
|
||||||
|
|
||||||
if (config === null) {
|
if (config === null) {
|
||||||
@ -119,11 +133,11 @@ class App extends React.Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (config.get('error')) {
|
if (config.get('error')) {
|
||||||
return this.configError(config);
|
return App.configError(config);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (config.get('isFetching')) {
|
if (config.get('isFetching')) {
|
||||||
return this.configLoading();
|
return <Loader active>Loading configuration...</Loader>;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (user == null) {
|
if (user == null) {
|
||||||
@ -134,20 +148,25 @@ class App extends React.Component {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout theme={styles}>
|
<Layout theme={styles}>
|
||||||
|
<Notifs
|
||||||
|
className={styles.notifsContainer}
|
||||||
|
CustomComponent={Toast}
|
||||||
|
/>
|
||||||
<NavDrawer
|
<NavDrawer
|
||||||
active={navDrawerIsVisible}
|
active={navDrawerIsVisible}
|
||||||
scrollY
|
scrollY
|
||||||
permanentAt={navDrawerIsVisible ? 'lg' : null}
|
permanentAt="lg"
|
||||||
|
onOverlayClick={this.toggleNavDrawer} // eslint-disable-line
|
||||||
theme={styles}
|
theme={styles}
|
||||||
>
|
>
|
||||||
<nav className={styles.nav}>
|
<nav className={styles.nav}>
|
||||||
<h1 className={styles.heading}>Collections</h1>
|
<h1 className={styles.heading}>Collections</h1>
|
||||||
<Navigation type='vertical'>
|
<Navigation type="vertical">
|
||||||
{
|
{
|
||||||
collections.valueSeq().map(collection =>
|
collections.valueSeq().map(collection =>
|
||||||
<Link
|
<Link
|
||||||
key={collection.get('name')}
|
key={collection.get('name')}
|
||||||
onClick={navigateToCollection.bind(this, collection.get('name'))}
|
onClick={navigateToCollection.bind(this, collection.get('name'))} // eslint-disable-line
|
||||||
>
|
>
|
||||||
{collection.get('label')}
|
{collection.get('label')}
|
||||||
</Link>
|
</Link>
|
||||||
@ -192,7 +211,7 @@ function mapDispatchToProps(dispatch) {
|
|||||||
},
|
},
|
||||||
createNewEntryInCollection: (collectionName) => {
|
createNewEntryInCollection: (collectionName) => {
|
||||||
dispatch(createNewEntryInCollection(collectionName));
|
dispatch(createNewEntryInCollection(collectionName));
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -7,12 +7,14 @@ import {
|
|||||||
createEmptyDraft,
|
createEmptyDraft,
|
||||||
discardDraft,
|
discardDraft,
|
||||||
changeDraft,
|
changeDraft,
|
||||||
persistEntry
|
persistEntry,
|
||||||
} from '../actions/entries';
|
} from '../actions/entries';
|
||||||
|
import { cancelEdit } from '../actions/editor';
|
||||||
import { addMedia, removeMedia } from '../actions/media';
|
import { addMedia, removeMedia } from '../actions/media';
|
||||||
import { selectEntry, getMedia } from '../reducers';
|
import { selectEntry, getMedia } from '../reducers';
|
||||||
import EntryEditor from '../components/EntryEditor';
|
import EntryEditor from '../components/EntryEditor/EntryEditor';
|
||||||
import EntryPageHOC from './editorialWorkflow/EntryPageHOC';
|
import entryPageHOC from './editorialWorkflow/EntryPageHOC';
|
||||||
|
import { Loader } from '../components/UI';
|
||||||
|
|
||||||
class EntryPage extends React.Component {
|
class EntryPage extends React.Component {
|
||||||
static propTypes = {
|
static propTypes = {
|
||||||
@ -28,17 +30,18 @@ class EntryPage extends React.Component {
|
|||||||
loadEntry: PropTypes.func.isRequired,
|
loadEntry: PropTypes.func.isRequired,
|
||||||
persistEntry: PropTypes.func.isRequired,
|
persistEntry: PropTypes.func.isRequired,
|
||||||
removeMedia: PropTypes.func.isRequired,
|
removeMedia: PropTypes.func.isRequired,
|
||||||
|
cancelEdit: PropTypes.func.isRequired,
|
||||||
slug: PropTypes.string,
|
slug: PropTypes.string,
|
||||||
newEntry: PropTypes.bool.isRequired,
|
newEntry: PropTypes.bool.isRequired,
|
||||||
};
|
};
|
||||||
|
|
||||||
componentDidMount() {
|
componentDidMount() {
|
||||||
const { entry, collection, slug } = this.props;
|
const { entry, newEntry, collection, slug, createEmptyDraft, loadEntry } = this.props;
|
||||||
|
|
||||||
if (this.props.newEntry) {
|
if (newEntry) {
|
||||||
this.props.createEmptyDraft(this.props.collection);
|
createEmptyDraft(collection);
|
||||||
} else {
|
} else {
|
||||||
this.props.loadEntry(entry, collection, slug);
|
loadEntry(entry, collection, slug);
|
||||||
this.createDraft(entry);
|
this.createDraft(entry);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -56,21 +59,31 @@ class EntryPage extends React.Component {
|
|||||||
this.props.discardDraft();
|
this.props.discardDraft();
|
||||||
}
|
}
|
||||||
|
|
||||||
createDraft = entry => {
|
createDraft = (entry) => {
|
||||||
if (entry) this.props.createDraftFromEntry(entry);
|
if (entry) this.props.createDraftFromEntry(entry);
|
||||||
};
|
};
|
||||||
|
|
||||||
handlePersistEntry = () => {
|
handlePersistEntry = () => {
|
||||||
this.props.persistEntry(this.props.collection, this.props.entryDraft);
|
const { persistEntry, collection, entryDraft } = this.props;
|
||||||
|
persistEntry(collection, entryDraft);
|
||||||
};
|
};
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const {
|
const {
|
||||||
entry, entryDraft, boundGetMedia, collection, changeDraft, addMedia, removeMedia
|
entry,
|
||||||
|
entryDraft,
|
||||||
|
boundGetMedia,
|
||||||
|
collection,
|
||||||
|
changeDraft,
|
||||||
|
addMedia,
|
||||||
|
removeMedia,
|
||||||
|
cancelEdit,
|
||||||
} = this.props;
|
} = this.props;
|
||||||
|
|
||||||
if (entryDraft == null || entryDraft.get('entry') == undefined || entry && entry.get('isFetching')) {
|
if (entryDraft == null
|
||||||
return <div>Loading...</div>;
|
|| entryDraft.get('entry') === undefined
|
||||||
|
|| (entry && entry.get('isFetching'))) {
|
||||||
|
return <Loader active>Loading entry...</Loader>;
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<EntryEditor
|
<EntryEditor
|
||||||
@ -81,18 +94,12 @@ class EntryPage extends React.Component {
|
|||||||
onAddMedia={addMedia}
|
onAddMedia={addMedia}
|
||||||
onRemoveMedia={removeMedia}
|
onRemoveMedia={removeMedia}
|
||||||
onPersist={this.handlePersistEntry}
|
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) {
|
function mapStateToProps(state, ownProps) {
|
||||||
const { collections, entryDraft } = state;
|
const { collections, entryDraft } = state;
|
||||||
const collection = collections.get(ownProps.params.name);
|
const collection = collections.get(ownProps.params.name);
|
||||||
@ -100,7 +107,15 @@ function mapStateToProps(state, ownProps) {
|
|||||||
const slug = ownProps.params.slug;
|
const slug = ownProps.params.slug;
|
||||||
const entry = newEntry ? null : selectEntry(state, collection.get('name'), slug);
|
const entry = newEntry ? null : selectEntry(state, collection.get('name'), slug);
|
||||||
const boundGetMedia = getMedia.bind(null, state);
|
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(
|
export default connect(
|
||||||
@ -113,6 +128,7 @@ export default connect(
|
|||||||
createDraftFromEntry,
|
createDraftFromEntry,
|
||||||
createEmptyDraft,
|
createEmptyDraft,
|
||||||
discardDraft,
|
discardDraft,
|
||||||
persistEntry
|
persistEntry,
|
||||||
|
cancelEdit,
|
||||||
}
|
}
|
||||||
)(EntryPage);
|
)(entryPageHOC(EntryPage));
|
||||||
|
@ -36,59 +36,6 @@ h1 {
|
|||||||
font-size: 25px;
|
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 {
|
:global {
|
||||||
& .rdt {
|
& .rdt {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
@ -1,4 +1,3 @@
|
|||||||
import expect from 'expect';
|
|
||||||
import Immutable from 'immutable';
|
import Immutable from 'immutable';
|
||||||
import { configLoaded, configLoading, configFailed } from '../../actions/config';
|
import { configLoaded, configLoading, configFailed } from '../../actions/config';
|
||||||
import config from '../config';
|
import config from '../config';
|
||||||
@ -14,9 +13,9 @@ describe('config', () => {
|
|||||||
|
|
||||||
it('should handle an update', () => {
|
it('should handle an update', () => {
|
||||||
expect(
|
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(
|
).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 { Map, OrderedMap, fromJS } from 'immutable';
|
||||||
import { entriesLoading, entriesLoaded } from '../../actions/entries';
|
import * as actions from '../../actions/entries';
|
||||||
import reducer from '../entries';
|
import reducer from '../entries';
|
||||||
|
|
||||||
|
const initialState = OrderedMap({
|
||||||
|
posts: Map({ name: 'posts' }),
|
||||||
|
});
|
||||||
|
|
||||||
describe('entries', () => {
|
describe('entries', () => {
|
||||||
it('should mark entries as fetching', () => {
|
it('should mark entries as fetching', () => {
|
||||||
const state = OrderedMap({
|
|
||||||
'posts': Map({ name: 'posts' })
|
|
||||||
});
|
|
||||||
expect(
|
expect(
|
||||||
reducer(state, entriesLoading(Map({ name: 'posts' })))
|
reducer(initialState, actions.entriesLoading(Map({ name: 'posts' })))
|
||||||
).toEqual(
|
).toEqual(
|
||||||
OrderedMap(fromJS({
|
OrderedMap(fromJS({
|
||||||
'posts': { name: 'posts' },
|
posts: { name: 'posts' },
|
||||||
'pages': {
|
pages: {
|
||||||
'posts': { isFetching: true }
|
posts: { isFetching: true },
|
||||||
}
|
},
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle loaded entries', () => {
|
it('should handle loaded entries', () => {
|
||||||
const state = OrderedMap({
|
|
||||||
'posts': Map({ name: 'posts' })
|
|
||||||
});
|
|
||||||
const entries = [{ slug: 'a', path: '' }, { slug: 'b', title: 'B' }];
|
const entries = [{ slug: 'a', path: '' }, { slug: 'b', title: 'B' }];
|
||||||
expect(
|
expect(
|
||||||
reducer(state, entriesLoaded(Map({ name: 'posts' }), entries))
|
reducer(initialState, actions.entriesLoaded(Map({ name: 'posts' }), entries, 0))
|
||||||
).toEqual(
|
).toEqual(
|
||||||
OrderedMap(fromJS(
|
OrderedMap(fromJS(
|
||||||
{
|
{
|
||||||
@ -37,9 +34,10 @@ describe('entries', () => {
|
|||||||
},
|
},
|
||||||
pages: {
|
pages: {
|
||||||
posts: {
|
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:
|
case AUTH_SUCCESS:
|
||||||
return Immutable.fromJS({ user: action.payload });
|
return Immutable.fromJS({ user: action.payload });
|
||||||
case AUTH_FAILURE:
|
case AUTH_FAILURE:
|
||||||
console.error(action.payload);
|
|
||||||
return Immutable.Map({ error: action.payload.toString() });
|
return Immutable.Map({ error: action.payload.toString() });
|
||||||
default:
|
default:
|
||||||
return state;
|
return state;
|
||||||
|
@ -1,8 +1,10 @@
|
|||||||
import { combineReducers } from 'redux';
|
import { combineReducers } from 'redux';
|
||||||
import { routerReducer } from 'react-router-redux';
|
import { routerReducer } from 'react-router-redux';
|
||||||
|
import { reducer as notifReducer } from 'redux-notifications';
|
||||||
import reducers from '.';
|
import reducers from '.';
|
||||||
|
|
||||||
export default combineReducers({
|
export default combineReducers({
|
||||||
...reducers,
|
...reducers,
|
||||||
routing: routerReducer
|
notifs: notifReducer,
|
||||||
|
routing: routerReducer,
|
||||||
});
|
});
|
||||||
|
@ -1,18 +1,26 @@
|
|||||||
import { Map, List, fromJS } from 'immutable';
|
import { Map, List, fromJS } from 'immutable';
|
||||||
import {
|
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';
|
} 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) => {
|
const entries = (state = Map({ entities: Map(), pages: Map() }), action) => {
|
||||||
switch (action.type) {
|
switch (action.type) {
|
||||||
case ENTRY_REQUEST:
|
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:
|
case ENTRY_SUCCESS:
|
||||||
return state.setIn(
|
return state.setIn(
|
||||||
['entities', `${action.payload.collection}.${action.payload.entry.slug}`],
|
['entities', `${ action.payload.collection }.${ action.payload.entry.slug }`],
|
||||||
fromJS(action.payload.entry)
|
fromJS(action.payload.entry)
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -24,15 +32,15 @@ const entries = (state = Map({ entities: Map(), pages: Map() }), action) => {
|
|||||||
loadedEntries = action.payload.entries;
|
loadedEntries = action.payload.entries;
|
||||||
page = action.payload.page;
|
page = action.payload.page;
|
||||||
return state.withMutations((map) => {
|
return state.withMutations((map) => {
|
||||||
loadedEntries.forEach((entry) => (
|
loadedEntries.forEach(entry => (
|
||||||
map.setIn(['entities', `${collection}.${entry.slug}`], fromJS(entry).set('isFetching', false))
|
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({
|
map.setIn(['pages', collection], Map({
|
||||||
page: page,
|
page,
|
||||||
ids: page === 0 ? ids : map.getIn(['pages', collection, 'ids'], List()).concat(ids)
|
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', 'isFetching'], true);
|
||||||
map.setIn(['search', 'term'], action.payload.searchTerm);
|
map.setIn(['search', 'term'], action.payload.searchTerm);
|
||||||
});
|
});
|
||||||
} else {
|
|
||||||
return state;
|
|
||||||
}
|
}
|
||||||
|
return state;
|
||||||
|
|
||||||
case SEARCH_ENTRIES_SUCCESS:
|
case SEARCH_ENTRIES_SUCCESS:
|
||||||
loadedEntries = action.payload.entries;
|
loadedEntries = action.payload.entries;
|
||||||
page = action.payload.page;
|
page = action.payload.page;
|
||||||
searchTerm = action.payload.searchTerm;
|
searchTerm = action.payload.searchTerm;
|
||||||
return state.withMutations((map) => {
|
return state.withMutations((map) => {
|
||||||
loadedEntries.forEach((entry) => (
|
loadedEntries.forEach(entry => (
|
||||||
map.setIn(['entities', `${entry.collection}.${entry.slug}`], fromJS(entry).set('isFetching', false))
|
map.setIn(['entities', `${ entry.collection }.${ entry.slug }`], fromJS(entry).set('isFetching', false))
|
||||||
));
|
));
|
||||||
const ids = List(loadedEntries.map(entry => ({ collection: entry.collection, slug: entry.slug })));
|
const ids = List(loadedEntries.map(entry => ({ collection: entry.collection, slug: entry.slug })));
|
||||||
map.set('search', Map({
|
map.set('search', Map({
|
||||||
page: page,
|
page,
|
||||||
term: searchTerm,
|
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) => (
|
export const selectEntry = (state, collection, slug) => (
|
||||||
state.getIn(['entities', `${collection}.${slug}`])
|
state.getIn(['entities', `${ collection }.${ slug }`])
|
||||||
);
|
);
|
||||||
|
|
||||||
export const selectEntries = (state, collection) => {
|
export const selectEntries = (state, collection) => {
|
||||||
const slugs = state.getIn(['pages', collection, 'ids']);
|
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) => {
|
export const selectSearchedEntries = (state) => {
|
||||||
|
@ -1,10 +1,21 @@
|
|||||||
import { Map, List, fromJS } from 'immutable';
|
import { Map, List, fromJS } from 'immutable';
|
||||||
import { DRAFT_CREATE_FROM_ENTRY, DRAFT_CREATE_EMPTY, DRAFT_DISCARD, DRAFT_CHANGE } from '../actions/entries';
|
import {
|
||||||
import { ADD_MEDIA, REMOVE_MEDIA } from '../actions/media';
|
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 initialState = Map({ entry: Map(), mediaFiles: List() });
|
||||||
|
|
||||||
const entryDraft = (state = Map(), action) => {
|
const entryDraftReducer = (state = Map(), action) => {
|
||||||
switch (action.type) {
|
switch (action.type) {
|
||||||
case DRAFT_CREATE_FROM_ENTRY:
|
case DRAFT_CREATE_FROM_ENTRY:
|
||||||
// Existing Entry
|
// Existing Entry
|
||||||
@ -25,14 +36,23 @@ const entryDraft = (state = Map(), action) => {
|
|||||||
case DRAFT_CHANGE:
|
case DRAFT_CHANGE:
|
||||||
return state.set('entry', action.payload);
|
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:
|
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:
|
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:
|
default:
|
||||||
return state;
|
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';
|
process.env.BABEL_ENV = 'test';
|
||||||
|
|
||||||
module.exports = wallaby => ({
|
module.exports = wallaby => ({
|
||||||
files: [
|
files: [
|
||||||
{ pattern: 'src/**/*.js' },
|
'package.json',
|
||||||
{ pattern: 'src/**/*.spec.js', ignore: true }
|
'src/**/*.js',
|
||||||
|
'src/**/*.js.snap',
|
||||||
|
'!src/**/*.spec.js',
|
||||||
],
|
],
|
||||||
|
|
||||||
tests: [
|
tests: ['src/**/*.spec.js'],
|
||||||
{ pattern: 'src/**/*.spec.js' }
|
|
||||||
],
|
|
||||||
|
|
||||||
compilers: {
|
compilers: {
|
||||||
'src/**/*.js': wallaby.compilers.babel()
|
'src/**/*.js': wallaby.compilers.babel(),
|
||||||
},
|
},
|
||||||
|
|
||||||
env: {
|
env: {
|
||||||
type: 'node',
|
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: {
|
module: {
|
||||||
loaders: [
|
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',
|
loader: 'url-loader?limit=100000',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
Loading…
x
Reference in New Issue
Block a user