diff --git a/invenio_app_rdm/administration/records/records.py b/invenio_app_rdm/administration/records/records.py
index 0552d7f47..ba30e5172 100644
--- a/invenio_app_rdm/administration/records/records.py
+++ b/invenio_app_rdm/administration/records/records.py
@@ -56,6 +56,11 @@ class RecordAdminListView(AdminResourceListView):
"payload_schema": None,
"order": 1,
},
+ "compare": {
+ "text": _("Diff"),
+ "payload_schema": None,
+ "order": 1,
+ },
}
search_config_name = "RDM_SEARCH"
diff --git a/invenio_app_rdm/theme/assets/semantic-ui/js/invenio_app_rdm/administration/records/CompareRevisions.js b/invenio_app_rdm/theme/assets/semantic-ui/js/invenio_app_rdm/administration/records/CompareRevisions.js
new file mode 100644
index 000000000..181774f37
--- /dev/null
+++ b/invenio_app_rdm/theme/assets/semantic-ui/js/invenio_app_rdm/administration/records/CompareRevisions.js
@@ -0,0 +1,150 @@
+/*
+ * // This file is part of Invenio-App-Rdm
+ * // Copyright (C) 2023 CERN.
+ * //
+ * // Invenio-App-Rdm is free software; you can redistribute it and/or modify it
+ * // under the terms of the MIT License; see LICENSE file for more details.
+ */
+
+import React, { Component } from "react";
+import PropTypes from "prop-types";
+import { NotificationContext } from "@js/invenio_administration";
+import { withCancel, ErrorMessage } from "react-invenio-forms";
+import { Button, Modal, Dropdown, Grid } from "semantic-ui-react";
+import { i18next } from "@translations/invenio_app_rdm/i18next";
+import { Differ, Viewer } from "json-diff-kit";
+
+export class CompareRevisions extends Component {
+ constructor(props) {
+ super(props);
+ this.state = {
+ loading: true,
+ error: undefined,
+ currentDiff: undefined,
+ revisions: {},
+ };
+ this.differ = new Differ({
+ detectCircular: true,
+ maxDepth: null,
+ showModifications: true,
+ arrayDiffMethod: "lcs",
+ ignoreCase: false,
+ ignoreCaseForKey: false,
+ recursiveEqual: true,
+ });
+ }
+
+ componentWillUnmount() {
+ this.cancellableAction && this.cancellableAction.cancel();
+ }
+
+ async componentDidMount() {
+ const { resource } = this.props;
+ this.setState({ loading: true });
+ const response = await fetch(
+ `https://127.0.0.1:5000/api/records/${resource.id}/revisions`
+ );
+ if (!response.ok) {
+ this.setState({ error: response.statusText, loading: false });
+ return;
+ }
+
+ const revisions = await response.json();
+ this.setState(
+ {
+ revisions: revisions.reduce((acc, item, index) => {
+ acc[index] = item; // Use version_id as the key
+ return acc;
+ }, {}),
+ },
+ () => {
+ this.setState({ loading: false });
+ }
+ );
+ }
+
+ static contextType = NotificationContext;
+
+ computeDiff = (before, after) => {
+ const diff = this.differ.diff(before, after);
+ this.setState({ currentDiff: diff });
+ };
+
+ handleModalClose = () => {
+ const { actionCancelCallback } = this.props;
+ actionCancelCallback();
+ };
+
+ render() {
+ const { error, loading, currentDiff } = this.state;
+
+ const viewerProps = {
+ indent: 4,
+ lineNumbers: true,
+ highlightInlineDiff: true,
+ inlineDiffOptions: {
+ mode: "word",
+ wordSeparator: " ",
+ },
+ hideUnchangedLines: true,
+ syntaxHighlight: false,
+ virtual: false,
+ };
+
+ return (
+ <>
+ {error && (
+
+ )}
+
+ {loading && Loading...
}
+
+
+ {
+ this.computeDiff(
+ this.state.revisions[value],
+ this.state.revisions[0]
+ );
+ this.setState({ selectedRevision: value });
+ }}
+ options={Object.values(this.state.revisions).map((rev, index) => ({
+ key: index,
+ text: `Revision ${index}`,
+ value: index,
+ }))}
+ />
+
+
+ {!loading && currentDiff && (
+
+ )}
+
+
+
+
+
+
+ >
+ );
+ }
+}
+
+CompareRevisions.propTypes = {
+ resource: PropTypes.object.isRequired,
+ actionCancelCallback: PropTypes.func.isRequired,
+ actionSuccessCallback: PropTypes.func.isRequired,
+};
diff --git a/invenio_app_rdm/theme/assets/semantic-ui/js/invenio_app_rdm/administration/records/RecordResourceActions.js b/invenio_app_rdm/theme/assets/semantic-ui/js/invenio_app_rdm/administration/records/RecordResourceActions.js
index 98bb71823..db6a33870 100644
--- a/invenio_app_rdm/theme/assets/semantic-ui/js/invenio_app_rdm/administration/records/RecordResourceActions.js
+++ b/invenio_app_rdm/theme/assets/semantic-ui/js/invenio_app_rdm/administration/records/RecordResourceActions.js
@@ -7,6 +7,7 @@
*/
import TombstoneForm from "./TombstoneForm";
+import {CompareRevisions} from "./CompareRevisions";
import React, { Component } from "react";
import PropTypes from "prop-types";
import { Button, Modal, Icon } from "semantic-ui-react";
@@ -28,6 +29,19 @@ export class RecordResourceActions extends Component {
onModalTriggerClick = (e, { payloadSchema, dataName, dataActionKey }) => {
const { resource } = this.props;
+ if (dataActionKey === "compare") {
+ this.setState({
+ modalOpen: true,
+ modalHeader: i18next.t("Compare with previous revision"),
+ modalBody: (
+
+ ),
+ });
+ }
if (dataActionKey === "delete") {
this.setState({
modalOpen: true,
@@ -81,6 +95,25 @@ export class RecordResourceActions extends Component {
return (
<>
{Object.entries(actions).map(([actionKey, actionConfig]) => {
+ if (actionKey === "compare" && !resource.deletion_status.is_deleted) {
+ icon = "file code outline";
+ return (
+
+ {icon && }
+ {actionConfig.text}
+
+ );
+ }
if (actionKey === "delete" && !resource.deletion_status.is_deleted) {
icon = "trash alternate";
return (
diff --git a/invenio_app_rdm/theme/assets/semantic-ui/js/invenio_app_rdm/administration/records/api/api.js b/invenio_app_rdm/theme/assets/semantic-ui/js/invenio_app_rdm/administration/records/api/api.js
index 4655d2d9f..3e0313cec 100644
--- a/invenio_app_rdm/theme/assets/semantic-ui/js/invenio_app_rdm/administration/records/api/api.js
+++ b/invenio_app_rdm/theme/assets/semantic-ui/js/invenio_app_rdm/administration/records/api/api.js
@@ -22,7 +22,13 @@ const restoreRecord = async (record) => {
return await http.post(APIRoutes.restore(record));
};
+const getRevisions = async (record) => {
+ return await http.post(APIRoutes.compare(record));
+};
+
+
export const RecordModerationApi = {
deleteRecord: deleteRecord,
restoreRecord: restoreRecord,
+ getRevisions: getRevisions,
};
diff --git a/invenio_app_rdm/theme/assets/semantic-ui/js/invenio_app_rdm/administration/records/api/routes.js b/invenio_app_rdm/theme/assets/semantic-ui/js/invenio_app_rdm/administration/records/api/routes.js
index c255cf76e..e5bba669f 100644
--- a/invenio_app_rdm/theme/assets/semantic-ui/js/invenio_app_rdm/administration/records/api/routes.js
+++ b/invenio_app_rdm/theme/assets/semantic-ui/js/invenio_app_rdm/administration/records/api/routes.js
@@ -12,6 +12,9 @@ const APIRoutesGenerators = {
delete: (record, idKeyPath = "id") => {
return `/api/records/${_get(record, idKeyPath)}/delete`;
},
+ compare: (record, idKeyPath = "id") => {
+ return `/api/records/${_get(record, idKeyPath)}/revisions`;
+ },
restore: (record, idKeyPath = "id") => {
return `/api/records/${_get(record, idKeyPath)}/restore`;
diff --git a/invenio_app_rdm/theme/assets/semantic-ui/js/invenio_app_rdm/administration/records/index.js b/invenio_app_rdm/theme/assets/semantic-ui/js/invenio_app_rdm/administration/records/index.js
index d243406f7..25820b878 100644
--- a/invenio_app_rdm/theme/assets/semantic-ui/js/invenio_app_rdm/administration/records/index.js
+++ b/invenio_app_rdm/theme/assets/semantic-ui/js/invenio_app_rdm/administration/records/index.js
@@ -4,6 +4,8 @@
// Invenio RDM is free software; you can redistribute it and/or modify it
// under the terms of the MIT License; see LICENSE file for more details.
+import 'json-diff-kit/dist/viewer-monokai.css';
+
import { initDefaultSearchComponents } from "@js/invenio_administration";
import { createSearchAppInit } from "@js/invenio_search_ui";
import { NotificationController } from "@js/invenio_administration";
diff --git a/invenio_app_rdm/theme/assets/semantic-ui/less/invenio_app_rdm/theme/globals/site.overrides b/invenio_app_rdm/theme/assets/semantic-ui/less/invenio_app_rdm/theme/globals/site.overrides
index fb267ce2c..4b906f6d6 100644
--- a/invenio_app_rdm/theme/assets/semantic-ui/less/invenio_app_rdm/theme/globals/site.overrides
+++ b/invenio_app_rdm/theme/assets/semantic-ui/less/invenio_app_rdm/theme/globals/site.overrides
@@ -552,3 +552,136 @@ dl.details-list {
vertical-align: sub;
}
}
+
+
+// JSON diff kit copied css
+
+.json-diff-viewer {
+ width: 100%;
+ border-collapse: collapse;
+ border-spacing: 0;
+ table-layout: fixed;
+
+ tr {
+ vertical-align: top;
+
+ .line-add {
+ background: #a5d6a7;
+ }
+
+ .line-remove {
+ background: #ef9a9a;
+ }
+
+ .line-modify {
+ background: #ffe082;
+ }
+
+ &:hover td {
+ position: relative;
+
+ &:before {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ background: rgba(0, 0, 0, 0.05);
+ content: '';
+ pointer-events: none;
+ }
+ }
+
+ &.message-line {
+ border-top: 1px solid;
+ border-bottom: 1px solid;
+ text-align: center;
+
+ td {
+ padding: 4px 0;
+ font-size: 12px;
+ }
+ }
+
+ &.expand-line {
+ text-align: center;
+
+ td {
+ padding: 4px 0;
+ }
+
+ &:hover td:before {
+ background: transparent;
+ }
+
+ .has-lines-before {
+ border-bottom: 1px solid;
+ }
+
+ .has-lines-after {
+ border-top: 1px solid;
+ }
+
+ button {
+ padding: 0;
+ border: none;
+ margin: 0 0.5em;
+ background: transparent;
+ color: #2196f3;
+ cursor: pointer;
+ font-size: 12px;
+ user-select: none;
+
+ &:hover {
+ text-decoration: underline;
+ }
+ }
+ }
+ }
+
+ td {
+ padding: 1px;
+ font-size: 0;
+
+ &.line-number {
+ box-sizing: content-box;
+ padding: 0 8px;
+ border-right: 1px solid;
+ font-family: monospace;
+ font-size: 14px;
+ text-align: right;
+ user-select: none;
+ }
+ }
+
+ pre {
+ overflow: hidden;
+ margin: 0;
+ font-size: 12px;
+ line-height: 16px;
+ white-space: pre-wrap;
+ word-break: break-all;
+
+ .inline-diff-add {
+ background: rgba(0, 0, 0, 0.08);
+ text-decoration: underline;
+ word-break: break-all;
+ }
+
+ .inline-diff-remove {
+ background: rgba(0, 0, 0, 0.08);
+ text-decoration: line-through;
+ word-break: break-all;
+ }
+ }
+
+ &-virtual pre {
+ overflow-x: auto;
+ white-space: pre;
+
+ &::-webkit-scrollbar {
+ display: none;
+ }
+ }
+}
+