Skip to content

Commit

Permalink
feature:admin can rollback to a previous version of an entry, display…
Browse files Browse the repository at this point in the history
…s diff
  • Loading branch information
Peter Dinh committed Sep 26, 2017
1 parent 0ddcdff commit e2229f6
Show file tree
Hide file tree
Showing 17 changed files with 758 additions and 3 deletions.
98 changes: 98 additions & 0 deletions admin/client/App/screens/Revision/actions.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
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 { currentList } = state.lists;
const { id } = state.item;
const { data, _id: rollbackId } = state.revisions.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);
});
});
};
};
46 changes: 46 additions & 0 deletions admin/client/App/screens/Revision/components/RevisionHeader.js
Original file line number Diff line number Diff line change
@@ -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 (
<GlyphButton
component={Link}
data-e2e-editform-header-back
glyph="chevron-left"
position="left"
to={backPath}
variant="link"
>
Back
</GlyphButton>
);
};

return (
<div style={styles.container}>
<span>{renderBack()}</span>
<FormInput noedit style={styles.title}>
Revisions for {id}
</FormInput>
</div>
);
};

const styles = {
container: {
borderBottom: '1px dashed #e1e1e1',
margin: 'auto',
paddingBottom: '1em',
},
title: {
fontSize: '1.3rem',
},
};

export default RevisionHeader;
123 changes: 123 additions & 0 deletions admin/client/App/screens/Revision/components/RevisionItem.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
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 => (
<ReactJson
src={json}
displayObjectSize={false}
displayDataTypes={false}
/>
);

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(
<tr key={k}>
<td>{k}</td>
<td>{rjv(currentItem[k])}</td>
<td>{rjv(revision[k])}</td>
</tr>
);
continue;
}
if (Object.prototype.toString.call(revision[k]) === '[object Object]') {
recursiveSearch(currentItem[k], revision[k]);
continue;
}
differences.push(
<tr key={k}>
<td>{k}</td>
<td>{currentItem[k]}</td>
<td>{revision[k]}</td>
</tr>
);
}
}
};

recursiveSearch(currentItem, data);

return differences;
};

const applyButton = (
<GlyphButton color="success" onClick={handleButtonClick}>
<ResponsiveText hiddenXS={`Apply`} visibleXS="Apply" />
</GlyphButton>
);

const cancelButton = (
<GlyphButton color="danger" onClick={() => selectRevision({})}>
<ResponsiveText hiddenXS={`Cancel`} visibleXS="Cancel" />
</GlyphButton>
);

return (
<div style={style}>
{revisions.map(revision => {
const active = selectedRevision._id === revision._id;
const { first, last } = revision.user.name;
return (
<div key={revision._id}>
<RevisionListItem active={active} noedit onClick={() => selectRevision(revision)}>
{moment(revision.time).format('YYYY-MM-DD hh:mm:ssa')} by {`${first} ${last}`}
</RevisionListItem>
{active
? <div className="RevisionsItem__table--container">
<table className="RevisionsItem__table">
<tr>
<th>Fields</th>
<th>Current</th>
<th>Rollback</th>
</tr>
{renderDifferences(revision.data)}
</table>
<div>
<Container>
{applyButton}&nbsp;{cancelButton}
</Container>
</div>
</div>
: null
}
</div>
);
}
)}
</div>
);
};

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);
98 changes: 98 additions & 0 deletions admin/client/App/screens/Revision/components/RevisionListItem.js
Original file line number Diff line number Diff line change
@@ -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 <Component {...props} />;
};

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;
4 changes: 4 additions & 0 deletions admin/client/App/screens/Revision/constants.js
Original file line number Diff line number Diff line change
@@ -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';
Loading

0 comments on commit e2229f6

Please sign in to comment.