Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feature/history-ui-content-rollback #4445

Open
wants to merge 4 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions admin/client/App/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -32,6 +33,7 @@ ReactDOM.render(
<IndexRoute component={Home} />
<Route path=":listId" component={List} />
<Route path=":listId/:itemId" component={Item} />
<Route path=":listId/:itemId/revisions" component={Revision} />
</Route>
</Router>
</Provider>,
Expand Down
18 changes: 18 additions & 0 deletions admin/client/App/screens/Item/components/EditForm.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -296,6 +298,19 @@ var EditForm = React.createClass({
/>
</Button>
)}
{!this.props.list.noedit && this.props.list.history && (
<GlyphButton
component={Link}
data-e2e-editform-history
glyph="versions"
position="left"
style={styles.historyButton}
to={`${Keystone.adminPath}/${this.props.list.id}/${this.props.data.id}/revisions`}
variant="link"
>
History
</GlyphButton>
)}
</div>
</FooterBar>
);
Expand Down Expand Up @@ -419,6 +434,9 @@ const styles = {
footerbarInner: {
height: theme.component.height, // FIXME aphrodite bug
},
historyButton: {
float: 'right',
},
deleteButton: {
float: 'right',
},
Expand Down
100 changes: 100 additions & 0 deletions admin/client/App/screens/Revision/actions.js
Original file line number Diff line number Diff line change
@@ -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);
});
});
};
};
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;
124 changes: 124 additions & 0 deletions admin/client/App/screens/Revision/components/RevisionItem.js
Original file line number Diff line number Diff line change
@@ -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 => (
<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 user = revision.user || revision.u;
const { first, last } = user;
return (
<div key={revision._id}>
<RevisionListItem active={active} noedit onClick={() => selectRevision(revision)}>
{moment(revision.time || revision.t).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 || revision.d)}
</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);
Loading