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
+ ?
+
+
+ Fields |
+ Current |
+ Rollback |
+
+ {renderDifferences(revision.data || revision.d)}
+
+
+
+ {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",