From 9275aaec90904ff4700ef10807953ad6d0a2f386 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A1ssio=20Zen?= Date: Wed, 8 Jun 2016 04:42:24 -0300 Subject: [PATCH] Moved draft state for an entry (when in edit mode) to Redux --- src/actions/entries.js | 133 ++++++++++++++++------- src/backends/backend.js | 11 +- src/backends/test-repo/implementation.js | 10 +- src/components/ControlPane.js | 8 +- src/components/EntryEditor.js | 47 ++------ src/components/Widgets/ImageControl.js | 4 +- src/containers/EntryPage.js | 62 ++++++++--- src/index.js | 2 + src/reducers/entryDraft.js | 34 ++++++ src/store/configureStore.js | 2 + src/valueObjects/ImageProxy.js | 5 +- 11 files changed, 206 insertions(+), 112 deletions(-) create mode 100644 src/reducers/entryDraft.js diff --git a/src/actions/entries.js b/src/actions/entries.js index a20d13d8..34b806c8 100644 --- a/src/actions/entries.js +++ b/src/actions/entries.js @@ -1,18 +1,31 @@ import { currentBackend } from '../backends/backend'; +/* + * Contant Declarations + */ export const ENTRY_REQUEST = 'ENTRY_REQUEST'; export const ENTRY_SUCCESS = 'ENTRY_SUCCESS'; export const ENTRY_FAILURE = 'ENTRY_FAILURE'; -export const ENTRY_PERSIST_REQUEST = 'ENTRY_PERSIST_REQUEST'; -export const ENTRY_PERSIST_SUCCESS = 'ENTRY_PERSIST_SUCCESS'; -export const ENTRY_PERSIST_FAILURE = 'ENTRY_PERSIST_FAILURE'; - export const ENTRIES_REQUEST = 'ENTRIES_REQUEST'; export const ENTRIES_SUCCESS = 'ENTRIES_SUCCESS'; export const ENTRIES_FAILURE = 'ENTRIES_FAILURE'; -export function entryLoading(collection, slug) { +export const DRAFT_CREATE = 'DRAFT_CREATE'; +export const DRAFT_DISCARD = 'DRAFT_DISCARD'; +export const DRAFT_CHANGE = 'DRAFT_CHANGE'; +export const DRAFT_ADD_MEDIA = 'DRAFT_ADD_MEDIA'; +export const DRAFT_REMOVE_MEDIA = 'DRAFT_REMOVE_MEDIA'; + +export const ENTRY_PERSIST_REQUEST = 'ENTRY_PERSIST_REQUEST'; +export const ENTRY_PERSIST_SUCCESS = 'ENTRY_PERSIST_SUCCESS'; +export const ENTRY_PERSIST_FAILURE = 'ENTRY_PERSIST_FAILURE'; + + +/* + * Simple Action Creators (Internal) + */ +function entryLoading(collection, slug) { return { type: ENTRY_REQUEST, payload: { @@ -22,7 +35,7 @@ export function entryLoading(collection, slug) { }; } -export function entryLoaded(collection, entry) { +function entryLoaded(collection, entry) { return { type: ENTRY_SUCCESS, payload: { @@ -32,35 +45,7 @@ export function entryLoaded(collection, entry) { }; } -export function entryPersisting(collection, entry) { - return { - type: ENTRY_PERSIST_REQUEST, - payload: { - collection: collection, - entry: entry - } - }; -} - -export function entryPersisted(collection, entry) { - return { - type: ENTRY_PERSIST_SUCCESS, - payload: { - collection: collection, - entry: entry - } - }; -} - -export function entryPersistFail(collection, entry, error) { - return { - type: ENTRIES_FAILURE, - error: 'Failed to persist entry', - payload: error.toString() - }; -} - -export function entriesLoading(collection) { +function entriesLoading(collection) { return { type: ENTRIES_REQUEST, payload: { @@ -69,7 +54,7 @@ export function entriesLoading(collection) { }; } -export function entriesLoaded(collection, entries, pagination) { +function entriesLoaded(collection, entries, pagination) { return { type: ENTRIES_SUCCESS, payload: { @@ -80,7 +65,7 @@ export function entriesLoaded(collection, entries, pagination) { }; } -export function entriesFailed(collection, error) { +function entriesFailed(collection, error) { return { type: ENTRIES_FAILURE, error: 'Failed to load entries', @@ -89,6 +74,74 @@ export function entriesFailed(collection, error) { }; } +function entryPersisting(collection, entry) { + return { + type: ENTRY_PERSIST_REQUEST, + payload: { + collection: collection, + entry: entry + } + }; +} + +function entryPersisted(collection, entry) { + return { + type: ENTRY_PERSIST_SUCCESS, + payload: { + collection: collection, + entry: entry + } + }; +} + +function entryPersistFail(collection, entry, error) { + return { + type: ENTRIES_FAILURE, + error: 'Failed to persist entry', + payload: error.toString() + }; +} + +/* + * Exported simple Action Creators + */ +export function createDraft(entry) { + return { + type: DRAFT_CREATE, + payload: entry + }; +} + +export function discardDraft() { + return { + type: DRAFT_DISCARD + }; +} + +export function changeDraft(entry) { + return { + type: DRAFT_CHANGE, + payload: entry + }; +} + +export function addMediaToDraft(mediaFile) { + return { + type: DRAFT_ADD_MEDIA, + payload: mediaFile + }; +} + +export function removeMediaFromDraft(mediaFile) { + return { + type: DRAFT_REMOVE_MEDIA, + payload: mediaFile + }; +} + +/* + * Exported Thunk Action Creators + */ export function loadEntry(collection, slug) { return (dispatch, getState) => { const state = getState(); @@ -107,8 +160,10 @@ export function loadEntries(collection) { const backend = currentBackend(state.config); dispatch(entriesLoading(collection)); - backend.entries(collection) - .then((response) => dispatch(entriesLoaded(collection, response.entries, response.pagination))); + backend.entries(collection).then( + (response) => dispatch(entriesLoaded(collection, response.entries, response.pagination)), + (error) => dispatch(entriesFailed(collection, error)) + ); }; } diff --git a/src/backends/backend.js b/src/backends/backend.js index 8bb51aa4..5b8c7108 100644 --- a/src/backends/backend.js +++ b/src/backends/backend.js @@ -67,15 +67,16 @@ class Backend { }; } - persist(collection, entry, mediaFiles) { - const entryData = entry.get('data').toJS(); + persist(collection, entryDraft) { + + const entryData = entryDraft.getIn(['entry', 'data']).toJS(); const entryObj = { - path: entry.get('path'), - slug: entry.get('slug'), + path: entryDraft.getIn(['entry', 'path']), + slug: entryDraft.getIn(['entry', 'slug']), raw: this.entryToRaw(collection, entryData) }; - return this.implementation.persist(collection, entryObj, mediaFiles.toJS()); + return this.implementation.persist(collection, entryObj, entryDraft.get('mediaFiles').toJS()); } entryToRaw(collection, entry) { diff --git a/src/backends/test-repo/implementation.js b/src/backends/test-repo/implementation.js index 157847da..5493fc6c 100644 --- a/src/backends/test-repo/implementation.js +++ b/src/backends/test-repo/implementation.js @@ -19,10 +19,11 @@ function getFileData(file) { } // Only necessary in test-repo, where images won't actually be persisted on server -function changeFilePathstoBase64(content, mediaFiles, base64Files) { +function changeFilePathstoBase64(mediaFolder, content, mediaFiles, base64Files) { let _content = content; + mediaFiles.forEach((media, index) => { - const reg = new RegExp('\\b' + media.uri + '\\b', 'g'); + const reg = new RegExp('\\b' + mediaFolder + '/' + media.name + '\\b', 'g'); _content = _content.replace(reg, base64Files[index]); }); @@ -74,12 +75,11 @@ export default class TestRepo { persist(collection, entry, mediaFiles = []) { return new Promise((resolve, reject) => { - Promise.all(mediaFiles.map((imageProxy) => getFileData(imageProxy.file))).then( + Promise.all(mediaFiles.map((file) => getFileData(file))).then( (base64Files) => { - const content = changeFilePathstoBase64(entry.raw, mediaFiles, base64Files); + const content = changeFilePathstoBase64(this.config.get('media_folder'), entry.raw, mediaFiles, base64Files); const folder = collection.get('folder'); const fileName = entry.path.substring(entry.path.lastIndexOf('/') + 1); - window.repoFiles[folder][fileName]['content'] = content; resolve({collection, entry}); }, diff --git a/src/components/ControlPane.js b/src/components/ControlPane.js index cd7c08de..ea6f7162 100644 --- a/src/components/ControlPane.js +++ b/src/components/ControlPane.js @@ -3,15 +3,15 @@ import Widgets from './Widgets'; export default class ControlPane extends React.Component { controlFor(field) { - const { entry } = this.props; + const { entry, onChange, onAddMedia, onRemoveMedia } = this.props; const widget = Widgets[field.get('widget')] || Widgets._unknown; return React.createElement(widget.Control, { key: field.get('name'), field: field, value: entry.getIn(['data', field.get('name')]), - onChange: (value) => this.props.onChange(entry.setIn(['data', field.get('name')], value)), - onAddMedia: (mediaFile) => this.props.onAddMedia(mediaFile), - onRemoveMedia: (mediaFile) => this.props.onRemoveMedia(mediaFile) + onChange: (value) => onChange(entry.setIn(['data', field.get('name')], value)), + onAddMedia: onAddMedia, + onRemoveMedia: onRemoveMedia }); } diff --git a/src/components/EntryEditor.js b/src/components/EntryEditor.js index 9bab99a9..38e6cd3c 100644 --- a/src/components/EntryEditor.js +++ b/src/components/EntryEditor.js @@ -1,44 +1,11 @@ import React from 'react'; -import Immutable from 'immutable'; import ControlPane from './ControlPane'; import PreviewPane from './PreviewPane'; export default class EntryEditor extends React.Component { - constructor(props) { - super(props); - this.state = { - entry: props.entry, - mediaFiles: Immutable.List() - }; - this.handleChange = this.handleChange.bind(this); - this.handleAddMedia = this.handleAddMedia.bind(this); - this.handleRemoveMedia = this.handleRemoveMedia.bind(this); - this.handleSave = this.handleSave.bind(this); - } - - handleChange(entry) { - this.setState({entry: entry}); - } - - handleAddMedia(mediaFile) { - this.setState({mediaFiles: this.state.mediaFiles.push(mediaFile)}); - } - - handleRemoveMedia(mediaFile) { - const newState = this.state.mediaFiles.filterNot((item) => item === mediaFile); - this.state = { - entry: this.props.entry, - mediaFiles: Immutable.List(newState) - }; - } - - handleSave() { - this.props.onPersist(this.state.entry, this.state.mediaFiles); - } render() { - const { collection, entry } = this.props; - + const { collection, entry, onChange, onAddMedia, onRemoveMedia, onPersist } = this.props; return

Entry in {collection.get('label')}

{entry && entry.get('title')}

@@ -46,17 +13,17 @@ export default class EntryEditor extends React.Component {
- +
- + ; } } diff --git a/src/components/Widgets/ImageControl.js b/src/components/Widgets/ImageControl.js index 5ca73aa7..df8572c2 100644 --- a/src/components/Widgets/ImageControl.js +++ b/src/components/Widgets/ImageControl.js @@ -62,8 +62,8 @@ export default class ImageControl extends React.Component { }); if (file) { - imageRef = new ImageProxy(file.name, file); - this.props.onAddMedia(imageRef); + imageRef = new ImageProxy(file.name, window.URL.createObjectURL(file, {oneTimeOnly: true})); + this.props.onAddMedia(file); } this.props.onChange(imageRef); diff --git a/src/containers/EntryPage.js b/src/containers/EntryPage.js index 584a1402..123e7040 100644 --- a/src/containers/EntryPage.js +++ b/src/containers/EntryPage.js @@ -1,32 +1,56 @@ import React from 'react'; import { connect } from 'react-redux'; -import { Map } from 'immutable'; -import { loadEntry, persist } from '../actions/entries'; +import { + loadEntry, + createDraft, + discardDraft, + changeDraft, + addMediaToDraft, + removeMediaFromDraft, + persist +} from '../actions/entries'; import { selectEntry } from '../reducers/entries'; import EntryEditor from '../components/EntryEditor'; class EntryPage extends React.Component { constructor(props) { super(props); - this.props.dispatch(loadEntry(props.collection, props.slug)); - + this.props.loadEntry(props.collection, props.slug); this.handlePersist = this.handlePersist.bind(this); } - handlePersist(entry, mediaFiles) { - this.props.dispatch(persist(this.props.collection, entry, mediaFiles)); + componentDidMount() { + if (this.props.entry) { + this.props.createDraft(this.props.entry); + } + } + + componentWillReceiveProps(nextProps) { + if (this.props.entry !== nextProps.entry && !nextProps.entry.get('isFetching')) { + this.props.createDraft(nextProps.entry); + } + } + + componentWillUnmount() { + this.props.discardDraft(); + } + + handlePersist() { + this.props.persist(this.props.collection, this.props.entryDraft); } render() { - const { entry, collection } = this.props; - if (entry == null || entry.get('isFetching')) { + const { entry, entryDraft, collection, handleDraftChange, handleDraftAddMedia, handleDraftRemoveMedia } = this.props; + if (entry == null || entryDraft.get('entry') == undefined || entry.get('isFetching')) { return
Loading...
; } - return ( ); @@ -34,12 +58,22 @@ class EntryPage extends React.Component { } function mapStateToProps(state, ownProps) { - const { collections } = state; + const { collections, entryDraft } = state; const collection = collections.get(ownProps.params.name); const slug = ownProps.params.slug; const entry = selectEntry(state, collection.get('name'), slug); - - return {collection, collections, slug, entry}; + return {collection, collections, entryDraft, slug, entry}; } -export default connect(mapStateToProps)(EntryPage); +export default connect( + mapStateToProps, + { + handleDraftChange: changeDraft, + handleDraftAddMedia: addMediaToDraft, + handleDraftRemoveMedia: removeMediaFromDraft, + loadEntry, + createDraft, + discardDraft, + persist + } +)(EntryPage); diff --git a/src/index.js b/src/index.js index 7f0ba47c..dd7e27f6 100644 --- a/src/index.js +++ b/src/index.js @@ -7,6 +7,8 @@ import 'file?name=index.html!../example/index.html'; const store = configureStore(); +window.store = store; + const el = document.createElement('div'); document.body.appendChild(el); diff --git a/src/reducers/entryDraft.js b/src/reducers/entryDraft.js new file mode 100644 index 00000000..cc260c86 --- /dev/null +++ b/src/reducers/entryDraft.js @@ -0,0 +1,34 @@ +import { Map, List } from 'immutable'; +import { DRAFT_CREATE, DRAFT_DISCARD, DRAFT_CHANGE, DRAFT_ADD_MEDIA, DRAFT_REMOVE_MEDIA } from '../actions/entries'; + +const initialState = Map({entry: Map(), mediaFiles: List()}); + +export function entryDraft(state = Map(), action) { + switch (action.type) { + case DRAFT_CREATE: + if (!action.payload) { + // New entry + return initialState; + } + // Existing Entry + return state.withMutations((state) => { + state.set('entry', action.payload); + state.set('mediaFiles', List()); + }); + case DRAFT_DISCARD: + return initialState; + case DRAFT_CHANGE: + return state.set('entry', action.payload); + + case DRAFT_ADD_MEDIA: + return state.update('mediaFiles', (list) => list.push(action.payload)); + + case DRAFT_REMOVE_MEDIA: + const mediaIndex = state.get('mediaFiles').indexOf(action.payload); + if (mediaIndex === -1) return state; + return state.update('mediaFiles', (list) => list.splice(mediaIndex, 1)); + + default: + return state; + } +} diff --git a/src/store/configureStore.js b/src/store/configureStore.js index d87b77a1..13784144 100644 --- a/src/store/configureStore.js +++ b/src/store/configureStore.js @@ -5,6 +5,7 @@ import { syncHistory, routeReducer } from 'react-router-redux'; import { auth } from '../reducers/auth'; import { config } from '../reducers/config'; import { entries } from '../reducers/entries'; +import { entryDraft } from '../reducers/entryDraft'; import { collections } from '../reducers/collections'; const reducer = combineReducers({ @@ -12,6 +13,7 @@ const reducer = combineReducers({ config, collections, entries, + entryDraft, router: routeReducer }); diff --git a/src/valueObjects/ImageProxy.js b/src/valueObjects/ImageProxy.js index 564d69a0..12560bb4 100644 --- a/src/valueObjects/ImageProxy.js +++ b/src/valueObjects/ImageProxy.js @@ -3,12 +3,11 @@ export const setConfig = (configObj) => { config = configObj; }; -export default function ImageProxy(value, file, uploaded = false) { +export default function ImageProxy(value, objectURL, uploaded = false) { this.value = value; - this.file = file; this.uploaded = uploaded; this.uri = config.media_folder && !uploaded ? config.media_folder + '/' + value : value; this.toString = function() { - return uploaded ? this.uri : window.URL.createObjectURL(this.file, {oneTimeOnly: true}); + return uploaded ? this.uri : objectURL; }; }