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; + } + } +} +