diff --git a/admin/client/App/index.js b/admin/client/App/index.js index a02ed2e08c..08cd26fbe3 100644 --- a/admin/client/App/index.js +++ b/admin/client/App/index.js @@ -15,6 +15,7 @@ import App from './App'; import Home from './screens/Home'; import Item from './screens/Item'; import List from './screens/List'; +import Revision from './screens/Revision'; import store from './store'; @@ -32,6 +33,7 @@ ReactDOM.render( + , diff --git a/admin/client/App/screens/Item/components/EditForm.js b/admin/client/App/screens/Item/components/EditForm.js index aa250dde43..68ce235358 100644 --- a/admin/client/App/screens/Item/components/EditForm.js +++ b/admin/client/App/screens/Item/components/EditForm.js @@ -7,9 +7,11 @@ import { FormInput, Grid, ResponsiveText, + GlyphButton, } from '../../../elemental'; import { Fields } from 'FieldTypes'; +import { Link } from 'react-router'; import { fade } from '../../../../utils/color'; import theme from '../../../../theme'; @@ -296,6 +298,19 @@ var EditForm = React.createClass({ /> )} + {!this.props.list.noedit && this.props.list.history && ( + + History + + )} ); @@ -419,6 +434,9 @@ const styles = { footerbarInner: { height: theme.component.height, // FIXME aphrodite bug }, + historyButton: { + float: 'right', + }, deleteButton: { float: 'right', }, diff --git a/admin/client/App/screens/Revision/actions.js b/admin/client/App/screens/Revision/actions.js new file mode 100644 index 0000000000..149ae52e80 --- /dev/null +++ b/admin/client/App/screens/Revision/actions.js @@ -0,0 +1,100 @@ +import xhr from 'xhr'; +import assign from 'object-assign'; + +import { + LOAD_REVISIONS, + DATA_LOADING_SUCCESS, + DATA_LOADING_ERROR, + SELECT_REVISION, +} from './constants'; + +export const loadRevisions = () => { + return (dispatch, getState) => { + dispatch({ type: LOAD_REVISIONS }); + const state = getState(); + const { singular, path } = state.lists.currentList; + const { id } = state.item; + const url = `${Keystone.adminPath}/api/${path}/${id}/revisions`; + xhr({ + url, + method: 'POST', + headers: assign({}, Keystone.csrf.header), + json: { + id, + list: singular, + }, + }, (err, resp, body) => { + if (err) return dispatch({ type: DATA_LOADING_ERROR, payload: err }); + // Pass the body as result or error, depending on the statusCode + if (resp.statusCode === 200) { + if (!body.length) { + dispatch(dataLoadingError(id)); + } else { + dispatch(dataLoaded(body)); + } + } else { + dispatch(dataLoadingError()); + } + }); + }; +}; + +export const dataLoaded = data => ({ + type: DATA_LOADING_SUCCESS, + payload: data, +}); + +export const dataLoadingError = id => ({ + type: DATA_LOADING_ERROR, + payload: id, +}); + +export const selectRevision = revision => { + return (dispatch, getState) => { + const state = getState(); + const id = state.revisions.selectedRevision._id; + if (id === revision._id) { + dispatch({ type: SELECT_REVISION, payload: {} }); + } else { + dispatch({ type: SELECT_REVISION, payload: revision }); + } + }; +}; + +export const applyChanges = router => { + return (dispatch, getState) => { + const state = getState(); + const { selectedRevision } = state.revisions; + const { currentList } = state.lists; + const { id } = state.item; + const data = selectedRevision.data || selectedRevision.d; + const { _id: rollbackId } = selectedRevision; + const { currentItem } = state.revisions; + const redirectUrl = `${Keystone.adminPath}/${currentList.path}/${id}`; + const file = { + filename: data.filename, + mimetype: data.mimetype, + path: data.path, + originalname: data.originalname, + size: data.size, + url: data.url, + }; + data.filename ? data.file = file : data.file = ''; // empty string to rollback to no file + // this is to account for text fields being undefined upon entry creation + for (const k in currentItem) { + if (!data[k]) data[k] = ''; + } + // delete target revision to prevent a clog of revisions of the same type + xhr({ + url: `${Keystone.adminPath}/api/${currentList.singular}/${rollbackId}/delete/revision`, + method: 'POST', + headers: assign({}, Keystone.csrf.header), + }, () => { + currentList.updateItem(id, data, (err, data) => { + // TODO proper error handling + dispatch({ type: SELECT_REVISION, payload: {} }); + router.push(redirectUrl); + }); + }); + }; +}; diff --git a/admin/client/App/screens/Revision/components/RevisionHeader.js b/admin/client/App/screens/Revision/components/RevisionHeader.js new file mode 100644 index 0000000000..21e8f09e23 --- /dev/null +++ b/admin/client/App/screens/Revision/components/RevisionHeader.js @@ -0,0 +1,46 @@ +import React from 'react'; +import { Link } from 'react-router'; +import { GlyphButton, FormInput } from '../../../elemental'; + +const RevisionHeader = ({ + id, + routeParams, +}) => { + const renderBack = () => { + const backPath = `${Keystone.adminPath}/${routeParams.listId}/${id}`; + return ( + + Back + + ); + }; + + return ( +
+ {renderBack()} + + Revisions for {id} + +
+ ); +}; + +const styles = { + container: { + borderBottom: '1px dashed #e1e1e1', + margin: 'auto', + paddingBottom: '1em', + }, + title: { + fontSize: '1.3rem', + }, +}; + +export default RevisionHeader; diff --git a/admin/client/App/screens/Revision/components/RevisionItem.js b/admin/client/App/screens/Revision/components/RevisionItem.js new file mode 100644 index 0000000000..bcc0419dfd --- /dev/null +++ b/admin/client/App/screens/Revision/components/RevisionItem.js @@ -0,0 +1,124 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import moment from 'moment'; +import ReactJson from 'react-json-view'; +import { GlyphButton, ResponsiveText, Container } from '../../../elemental'; +import RevisionListItem from './RevisionListItem'; +import deepEql from '../utils/deepEql'; +import { selectRevision } from '../actions'; + +const RevisionItem = ({ + currentItem, + handleButtonClick, + revisions, + router, + selectedRevision, + selectRevision, +}) => { + const renderDifferences = data => { + const differences = []; + const rjv = json => ( + + ); + + const recursiveSearch = (currentItem, revision) => { + for (const k in currentItem) { + if (k === 'updatedBy' || k === 'updatedAt') continue; + // when we're comparing relationship fields, ignore _id property since that gets updated after every save + if (!deepEql(currentItem[k], revision[k], '_id')) { + if (Array.isArray(currentItem[k])) { + differences.push( + + {k} + {rjv(currentItem[k])} + {rjv(revision[k])} + + ); + continue; + } + if (Object.prototype.toString.call(revision[k]) === '[object Object]') { + recursiveSearch(currentItem[k], revision[k]); + continue; + } + differences.push( + + {k} + {currentItem[k]} + {revision[k]} + + ); + } + } + }; + + recursiveSearch(currentItem, data); + + return differences; + }; + + const applyButton = ( + + + + ); + + const cancelButton = ( + selectRevision({})}> + + + ); + + return ( +
+ {revisions.map(revision => { + const active = selectedRevision._id === revision._id; + const user = revision.user || revision.u; + const { first, last } = user; + return ( +
+ selectRevision(revision)}> + {moment(revision.time || revision.t).format('YYYY-MM-DD hh:mm:ssa')} by {`${first} ${last}`} + + {active + ?
+ + + + + + + {renderDifferences(revision.data || revision.d)} +
FieldsCurrentRollback
+
+ + {applyButton} {cancelButton} + +
+
+ : null + } +
+ ); + } + )} +
+ ); +}; + +const style = { + textAlign: 'center', +}; + +const mapStateToProps = state => ({ + currentItem: state.revisions.currentItem, + revisions: state.revisions.revisions, + selectedRevision: state.revisions.selectedRevision, +}); + +export default connect(mapStateToProps, { + selectRevision, +})(RevisionItem); diff --git a/admin/client/App/screens/Revision/components/RevisionListItem.js b/admin/client/App/screens/Revision/components/RevisionListItem.js new file mode 100644 index 0000000000..f933024a6a --- /dev/null +++ b/admin/client/App/screens/Revision/components/RevisionListItem.js @@ -0,0 +1,98 @@ +import React, { PropTypes } from 'react'; +import { css, StyleSheet } from 'aphrodite/no-important'; + +import theme from '../../../../theme'; +import { fade } from '../../../../utils/color'; + +/* eslint quote-props: ["error", "as-needed"] */ + +const RevisionListItem = ({ + className, + component: Component, + cropText, + multiline, + noedit, // NOTE not used, just removed from props + type, + active, + ...props +}) => { + props.className = css( + classes.noedit, + cropText ? classes.cropText : null, + multiline ? classes.multiline : null, + active ? classes.anchor : null, + className + ); + + return ; +}; + +RevisionListItem.propTypes = { + component: PropTypes.oneOfType([ + PropTypes.string, + PropTypes.func, + ]), + cropText: PropTypes.bool, +}; +RevisionListItem.defaultProps = { + component: 'span', +}; + +const anchorHoverAndFocusStyles = { + backgroundColor: fade(theme.color.link, 10), + borderColor: fade(theme.color.link, 10), + color: theme.color.link, + outline: 'none', + textDecoration: 'underline', +}; + +const classes = StyleSheet.create({ + noedit: { + appearance: 'none', + backgroundColor: theme.input.background.noedit, + backgroundImage: 'none', + borderColor: theme.input.border.color.noedit, + borderRadius: theme.input.border.radius, + borderStyle: 'solid', + borderWidth: theme.input.border.width, + color: theme.color.gray80, + cursor: 'pointer', + display: 'inline-block', + height: theme.input.height, + lineHeight: theme.input.lineHeight, + margin: '.1em 0', + padding: `0 ${theme.input.paddingHorizontal}`, + transition: 'border-color ease-in-out 0.15s, box-shadow ease-in-out 0.15s', + verticalAlign: 'middle', + width: '100%', + + // prevent empty inputs from collapsing by adding content + ':empty:before': { + color: theme.color.gray40, + content: '"(no value)"', + }, + }, + + multiline: { + display: 'block', + height: 'auto', + lineHeight: '1.4', + paddingBottom: '0.6em', + paddingTop: '0.6em', + }, + + // indicate clickability when using an anchor + anchor: { + backgroundColor: fade(theme.color.link, 10), + borderColor: fade(theme.color.link, 10), + color: theme.color.link, + marginRight: 5, + minWidth: 0, + textDecoration: 'none', + + ':hover': anchorHoverAndFocusStyles, + ':focus': anchorHoverAndFocusStyles, + }, +}); + +export default RevisionListItem; diff --git a/admin/client/App/screens/Revision/constants.js b/admin/client/App/screens/Revision/constants.js new file mode 100644 index 0000000000..82ccf797ee --- /dev/null +++ b/admin/client/App/screens/Revision/constants.js @@ -0,0 +1,4 @@ +export const LOAD_REVISIONS = 'app/Revision/LOAD_REVISIONS'; +export const DATA_LOADING_SUCCESS = 'app/Revision/DATA_LOADING_SUCCESS'; +export const DATA_LOADING_ERROR = 'app/Revision/DATA_LOADING_ERROR'; +export const SELECT_REVISION = 'app/Revision/SELECT_REVISION'; diff --git a/admin/client/App/screens/Revision/index.js b/admin/client/App/screens/Revision/index.js new file mode 100644 index 0000000000..922292d74c --- /dev/null +++ b/admin/client/App/screens/Revision/index.js @@ -0,0 +1,124 @@ +import React, { Component, PropTypes } from 'react'; +import moment from 'moment'; +import { Link } from 'react-router'; +import { connect } from 'react-redux'; + +import Alert from '../../elemental/Alert'; +import RevisionHeader from './components/RevisionHeader'; +import RevisionItem from './components/RevisionItem'; +import { Center, Container, Spinner } from '../../elemental'; +import ConfirmationDialog from '../../shared/ConfirmationDialog'; + +import { selectList } from '../List/actions'; +import { selectItem } from '../Item/actions'; +import { applyChanges, loadRevisions, selectRevision } from './actions'; + +class Revision extends Component { + static contextTypes = { + router: PropTypes.object.isRequired, + } + + constructor(props) { + super(props); + this.state = { isConfirmationOpen: false }; + } + + componentDidMount () { + // When we directly navigate to an item without coming from another client + // side routed page before, we need to select the list before initializing the item + // and then load the revisions + if (!this.props.currentList || this.props.currentList.id !== this.props.params.listId) { + this.props.selectList(this.props.params.listId); + this.props.selectItem(this.props.params.itemId); + } + this.props.loadRevisions(); + } + + componentWillUnmount() { + this.props.selectRevision({}); + } + + removeConfirmationDialog = cb => { + this.setState({ isConfirmationOpen: false }, cb); + } + + handleButtonClick = () => { + this.setState({ isConfirmationOpen: true }); + } + + renderError = () => { + return ( + + + {this.props.error}.  + + Go back to {this.props.routeParams.listId}? + + + + ); + } + + renderConfirmationDialog = () => ( + this.removeConfirmationDialog(this.props.applyChanges.bind(null, this.context.router))} + > +
+ Are you sure you want to rollback to this version? +
+
+ ) + + render() { + if (!this.props.ready) { + return ( +
+ +
+ ); + } + if (this.props.error) { + return this.renderError(); + } + + return ( +
+ + + {this.renderConfirmationDialog()} +
+ ); + } +}; + +const styles = { + container: { + padding: '1em 20px', + margin: '0 auto', + maxWidth: 1170, + }, +} + +const mapStateToProps = state => ({ + currentList: state.lists.currentList, + revisions: state.revisions.revisions, + id: state.item.id, + // this is similar to state.item but its shape is different + selectedRevision: state.revisions.selectedRevision, + error: state.revisions.error, + ready: state.revisions.ready +}); + +export default connect(mapStateToProps, { + applyChanges, + selectList, + selectItem, + selectRevision, + loadRevisions, +})(Revision); diff --git a/admin/client/App/screens/Revision/reducer.js b/admin/client/App/screens/Revision/reducer.js new file mode 100644 index 0000000000..edaae25459 --- /dev/null +++ b/admin/client/App/screens/Revision/reducer.js @@ -0,0 +1,32 @@ +import { + LOAD_REVISIONS, + DATA_LOADING_SUCCESS, + DATA_LOADING_ERROR, + SELECT_REVISION, +} from './constants'; + +export default (state = { + revisions: [], + currentItem: {}, + selectedRevision: {}, + error: null, + ready: false, +}, action) => { + switch (action.type) { + case LOAD_REVISIONS: + return { ...state, error: null, ready: false }; + case DATA_LOADING_SUCCESS: + const popped = action.payload.pop(); + const currentItem = popped.data || popped.d; + return { ...state, revisions: action.payload, error: null, ready: true, currentItem }; + case DATA_LOADING_ERROR: + if (action.payload) { + return { ...state, ready: true, error: `No item matching id ${action.payload}` }; + } + return { ...state, ready: true, error: 'Query error' }; + case SELECT_REVISION: + return { ...state, selectedRevision: action.payload }; + default: + return state; + } +}; diff --git a/admin/client/App/screens/Revision/test/actions.test.js b/admin/client/App/screens/Revision/test/actions.test.js new file mode 100644 index 0000000000..900bc36889 --- /dev/null +++ b/admin/client/App/screens/Revision/test/actions.test.js @@ -0,0 +1,33 @@ +import demand from 'must'; +import { + selectRevision, + dataLoaded, + dataLoadingError, +} from '../actions'; +import { + SELECT_REVISION, + DATA_LOADING_SUCCESS, + DATA_LOADING_ERROR, +} from '../constants'; + +describe(' actions', () => { + describe('dataLoaded', () => { + it('should have a type of DATA_LOADING_SUCCESS', () => { + demand(dataLoaded().type).eql(DATA_LOADING_SUCCESS); + }); + + it('should pass on the data', () => { + demand(dataLoaded({ some: 'data' }).payload).eql({ some: 'data' }); + }); + }); + + describe('dataLoadingError', () => { + it('should have a type of DATA_LOADING_ERROR', () => { + demand(dataLoadingError().type).eql(DATA_LOADING_ERROR); + }); + + it('should pass on the id', () => { + demand(dataLoadingError('foobar').payload).eql('foobar'); + }); + }); +}); diff --git a/admin/client/App/screens/Revision/test/reducer.test.js b/admin/client/App/screens/Revision/test/reducer.test.js new file mode 100644 index 0000000000..4e27110855 --- /dev/null +++ b/admin/client/App/screens/Revision/test/reducer.test.js @@ -0,0 +1,88 @@ +import demand from 'must'; +import revisionReducer from '../reducer'; +import { + LOAD_REVISIONS, + DATA_LOADING_SUCCESS, + DATA_LOADING_ERROR, + SELECT_REVISION, +} from '../constants'; + +describe(' reducer', () => { + it('should return the initial state', () => { + demand(revisionReducer(undefined, {})).eql({ + revisions: [], + currentItem: {}, + selectedRevision: {}, + error: null, + ready: false, + }); + }); + + describe('LOAD_REVISIONS', () => { + const state = { + error: {}, + ready: true, + }; + + it('should set ready to false', () => { + demand(revisionReducer(state, { + type: LOAD_REVISIONS, + }).ready).false(); + }); + + it('should set error to null', () => { + demand(revisionReducer(state, { + type: LOAD_REVISIONS, + }).error).eql(null); + }); + }); + + describe('DATA_LOADING_SUCCESS', () => { + const state = { + revisions: [], + currentItem: {}, + error: null, + ready: false, + }; + + it('should set revisions to the data passed in except for the last item', () => { + demand(revisionReducer(state, { + type: DATA_LOADING_SUCCESS, + payload: ['some', 'revisions'], + }).revisions).eql(['some']); + }); + + it('should set currentItem to the last item of the data passed in', () => { + demand(revisionReducer(state, { + type: DATA_LOADING_SUCCESS, + payload: ['some', { data: 'item' }], + }).currentItem).eql('item'); + }); + }); + + describe('DATA_LOADING_ERROR', () => { + const state = { + error: null, + }; + + it('should set error to the error object passed in', () => { + demand(revisionReducer(state, { + type: DATA_LOADING_ERROR, + payload: 'foobar', + }).error).eql('No item matching id foobar'); + }); + }); + + describe('SELECT_REVISION', () => { + const state = { + selectedRevision: {}, + }; + + it('should set the selectedRevision to the data passed in', () => { + demand(revisionReducer(state, { + type: SELECT_REVISION, + payload: { some: 'revision' }, + }).selectedRevision).eql({ some: 'revision' }); + }); + }); +}); diff --git a/admin/client/App/screens/Revision/utils/deepEql.js b/admin/client/App/screens/Revision/utils/deepEql.js new file mode 100644 index 0000000000..9fb858b017 --- /dev/null +++ b/admin/client/App/screens/Revision/utils/deepEql.js @@ -0,0 +1,34 @@ +const deepEql = (obj, oth, omitters) => { + const a = { ...obj }; + const b = { ...oth }; + const { keys } = Object; + if (Object.prototype.toString.call(a) !== Object.prototype.toString.call(b)) { + return false; + } + if (Array.isArray(a)) { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) { + if (typeof a[i] === 'object') { + if (!deepEql(a[i], b[i])) return false; + continue; + } + if (a[i] !== b[i]) return false; + } + } + if (!Array.isArray(omitters)) omitters = [omitters]; + omitters.forEach(omitter => { + delete a[omitter]; + delete b[omitter]; + }); + if (keys(a).length !== keys(b).length) return false; + for (const k in a) { + if (typeof a[k] === 'object') { + if (!deepEql(a[k], b[k], omitters)) return false; + continue; + } + if (a[k] !== b[k]) return false; + } + return true; +}; + +export default deepEql; diff --git a/admin/client/App/store.js b/admin/client/App/store.js index 40b01a96d7..149350eb85 100644 --- a/admin/client/App/store.js +++ b/admin/client/App/store.js @@ -8,6 +8,7 @@ import listsReducer from './screens/List/reducers/main'; import activeReducer from './screens/List/reducers/active'; import itemReducer from './screens/Item/reducer'; import homeReducer from './screens/Home/reducer'; +import revisionsReducer from './screens/Revision/reducer'; import rootSaga from './sagas'; @@ -15,6 +16,7 @@ import rootSaga from './sagas'; // Combine the reducers to one state const reducers = combineReducers({ lists: listsReducer, + revisions: revisionsReducer, active: activeReducer, item: itemReducer, home: homeReducer, diff --git a/admin/client/utils/List.js b/admin/client/utils/List.js index 8926d0e6c3..adfc5e1b41 100644 --- a/admin/client/utils/List.js +++ b/admin/client/utils/List.js @@ -116,16 +116,17 @@ List.prototype.createItem = function (formData, callback) { * Update a specific item * * @param {String} id The id of the item we want to update - * @param {FormData} formData The submitted form data + * @param {FormData|Object} changes The submitted form data or a data object * @param {Function} callback Called after the API call */ -List.prototype.updateItem = function (id, formData, callback) { +List.prototype.updateItem = function (id, changes, callback) { xhr({ url: `${Keystone.adminPath}/api/${this.path}/${id}`, responseType: 'json', method: 'POST', headers: assign({}, Keystone.csrf.header), - body: formData, + // if we're passing in formData, pass as a body, if not, pass as json object + [!Object.keys(changes).length ? 'body' : 'json']: changes, }, (err, resp, data) => { if (err) return callback(err); if (resp.statusCode === 200) { diff --git a/admin/public/styles/keystone.less b/admin/public/styles/keystone.less index f5708c1b0c..0bab686104 100644 --- a/admin/public/styles/keystone.less +++ b/admin/public/styles/keystone.less @@ -33,6 +33,7 @@ @import "keystone/list-dropzone.less"; @import "keystone/item.less"; @import "keystone/toolbar.less"; +@import "keystone/revision.less"; // COMPONENTS @import "keystone/wysiwyg.less"; diff --git a/admin/public/styles/keystone/revision.less b/admin/public/styles/keystone/revision.less new file mode 100644 index 0000000000..a7a999160d --- /dev/null +++ b/admin/public/styles/keystone/revision.less @@ -0,0 +1,44 @@ +// +// Revision view +// ============================== + +// Revisions Table +// the table itself and its components +// ------------------------------ + +.RevisionsItem__table--container { + margin: .5rem; + div { + margin: 0 auto; + } + + button { + margin-top: .5rem; + } +} + +.RevisionsItem__table { + border-collapse: collapse; + width: 100%; + th, td { + border: 1px solid #dddddd; + padding: .5rem; + text-align: left; + } + + th:nth-child(1) { + width: 10%; + } + + th:nth-child(1n+2) { + width: 45%; + } + + tr:nth-child(even) { + background-color: #F7F7F7; + } +} + +.RevisionHeader__title { + font-size: 1.3rem; +} diff --git a/admin/server/api/revision/delete.js b/admin/server/api/revision/delete.js new file mode 100644 index 0000000000..89a88ec563 --- /dev/null +++ b/admin/server/api/revision/delete.js @@ -0,0 +1,13 @@ +module.exports = (req, res) => { + const { keystone } = req; + if (!keystone.security.csrf.validate(req)) { + return res.apiError(403, 'invalid csrf'); + } + + const list = req.body.list || req.params.list; + const target = req.body.rollback || req.params.rollback; + const revisions = keystone.lists[list].HistoryModel; + + revisions.findById(target).remove().exec(); + res.status(200).send({ details: 'Deletion successful' }); +}; diff --git a/admin/server/api/revision/get.js b/admin/server/api/revision/get.js new file mode 100644 index 0000000000..9df2865ccc --- /dev/null +++ b/admin/server/api/revision/get.js @@ -0,0 +1,15 @@ +module.exports = (req, res) => { + const { keystone } = req; + if (!keystone.security.csrf.validate(req)) { + return res.apiError(403, 'invalid csrf'); + } + const id = req.body.id || req.params.id; + const list = req.body.list || req.params.list; + const revisions = keystone.lists[list].HistoryModel; + + revisions.find({ id }) + .populate('user', 'name') + .populate('u', 'name') + .then(items => res.json(items)) + .catch(err => res.json(err)); +}; diff --git a/admin/server/app/createDynamicRouter.js b/admin/server/app/createDynamicRouter.js index 1ce9402ee5..42de2ebf94 100644 --- a/admin/server/app/createDynamicRouter.js +++ b/admin/server/app/createDynamicRouter.js @@ -89,10 +89,14 @@ module.exports = function createDynamicRouter (keystone) { router.post('/api/:list/:id', initList, require('../api/item/update')); router.post('/api/:list/:id/delete', initList, require('../api/list/delete')); router.post('/api/:list/:id/sortOrder/:sortOrder/:newOrder', initList, require('../api/item/sortOrder')); + // revisions + router.post('/api/:list/:id/revisions', initList, require('../api/revision/get')); + router.post('/api/:list/:rollback/delete/revision', initList, require('../api/revision/delete')); // #6: List Routes router.all('/:list/:page([0-9]{1,5})?', IndexRoute); router.all('/:list/:item', IndexRoute); + router.all('/:list/:item/revisions', IndexRoute); // TODO: catch 404s and errors with Admin-UI specific handlers diff --git a/lib/list/getOptions.js b/lib/list/getOptions.js index 087f963a56..de39e27ce0 100644 --- a/lib/list/getOptions.js +++ b/lib/list/getOptions.js @@ -11,6 +11,7 @@ function getOptions () { defaultSort: this.options.defaultSort, fields: {}, hidden: this.options.hidden, + history: this.options.history, initialFields: _.map(this.initialFields, 'path'), key: this.key, label: this.label, diff --git a/lib/schemaPlugins/history.js b/lib/schemaPlugins/history.js index 5d47fae34c..33791f5fb9 100644 --- a/lib/schemaPlugins/history.js +++ b/lib/schemaPlugins/history.js @@ -1,6 +1,6 @@ var keystone = require('../../'); -var historyModelSuffix = '_revisions'; +const historyModelSuffix = '_revisions'; function getHistoryModelName (list) { return list.options.schema.collection + historyModelSuffix; @@ -8,14 +8,14 @@ function getHistoryModelName (list) { function getHistoryModel (list, userModel) { - var collection = getHistoryModelName(list); + const collection = getHistoryModelName(list); - var schema = new keystone.mongoose.Schema({ - i: { type: keystone.mongoose.Schema.Types.ObjectId, ref: collection }, - t: { type: Date, index: true, required: true }, - o: { type: String, index: true, required: true }, - c: { type: [String], index: true }, - d: { type: keystone.mongoose.Schema.Types.Mixed, required: true }, + const schema = new keystone.mongoose.Schema({ + id: { type: keystone.mongoose.Schema.Types.ObjectId, ref: collection }, + time: { type: Date, index: true, required: true }, + operation: { type: String, index: true, required: true }, + changes: { type: [String], index: true }, + data: { type: keystone.mongoose.Schema.Types.Mixed, required: true }, }, { id: true, versionKey: false, @@ -23,7 +23,7 @@ function getHistoryModel (list, userModel) { if (userModel) { schema.add({ - u: { type: keystone.mongoose.Schema.Types.ObjectId, ref: userModel }, + user: { type: keystone.mongoose.Schema.Types.ObjectId, ref: userModel }, }); } @@ -39,10 +39,10 @@ function getHistoryModel (list, userModel) { module.exports = function history () { - var list = this; + const list = this; // If model already exists for a '_revisions' in an inherited model, log a warning but skip creating the new model (inherited _revisions model will be used). - var collectionName = getHistoryModelName(list); + const collectionName = getHistoryModelName(list); if (list.get('inherits') && collectionName.indexOf(historyModelSuffix, collectionName.length - historyModelSuffix.length) !== -1 && keystone.mongoose.models[collectionName]) { @@ -50,61 +50,65 @@ module.exports = function history () { return; } - var userModel = keystone.get('user model'); + const userModel = keystone.get('user model'); - var HistoryModel = list.HistoryModel = getHistoryModel(this, userModel); + const HistoryModel = list.HistoryModel = getHistoryModel(this, userModel); list.schema.add({ __rev: Number, }); + list.schema.pre('save', function (next) { - this.__rev = (typeof this.__rev === 'number') ? this.__rev + 1 : 1; - - var data = this.toObject(); - delete data._id; - delete data.__v; - delete data.__rev; - - var doc = { - i: this.id, - t: Date.now(), - o: this.isNew ? 'c' : 'u', - c: [], - d: data, - }; + if (this.isModified()) { + this.__rev = (typeof this.__rev === 'number') ? this.__rev + 1 : 1; + + const data = this.toObject(); + delete data._id; + delete data.__v; + delete data.__rev; + + const doc = { + id: this.id, + time: Date.now(), + operation: this.isNew ? 'create' : 'update', + changes: [], + data: data, + }; + + for (const path in list.fields) { + if (this.isModified(path)) { + doc.changes.push(path); + } + } - for (var path in list.fields) { - if (this.isModified(path)) { - doc.c.push(path); + if (list.autokey) { + if (this.isModified(list.autokey.path)) { + doc.changes.push(list.autokey.path); + } } - } - if (list.autokey) { - if (this.isModified(list.autokey.path)) { - doc.c.push(list.autokey.path); + if (userModel && this._req_user) { + doc.user = this._req_user; } - } - if (userModel && this._req_user) { - doc.u = this._req_user; + new HistoryModel(doc).save(next); } - - new HistoryModel(doc).save(next); + next(); }); list.schema.pre('remove', function (next) { - var data = this.toObject(); + const data = this.toObject(); data.__v = undefined; - var doc = { - t: Date.now(), - o: 'd', - d: data, + const doc = { + time: Date.now(), + operation: 'delete', + data: data, }; if (userModel && this._req_user) { - doc.u = this._req_user; + doc.user = this._req_user; } new HistoryModel(doc).save(next); diff --git a/package.json b/package.json index cc977b5d0c..3c119af474 100644 --- a/package.json +++ b/package.json @@ -77,6 +77,7 @@ "react-dom": "15.4.2", "react-domify": "0.2.6", "react-images": "0.5.2", + "react-json-view": "^1.13.0", "react-markdown": "2.4.5", "react-redux": "5.0.3", "react-router": "3.0.2",