├── .nvmrc ├── .gitignore ├── admin └── src │ ├── translations │ ├── fr.json │ ├── en.json │ └── pt-BR.json │ ├── utils │ ├── getTrad.js │ ├── getPaginationList.js │ ├── buildPayload.js │ ├── getUser.js │ └── returnFieldType.js │ ├── pluginId.js │ ├── components │ ├── PluginIcon │ │ └── index.js │ ├── Initializer │ │ └── index.js │ ├── MediaField │ │ └── MediaField.js │ ├── TrailsTablePagination │ │ └── TrailsTablePagination.js │ ├── CheckboxPT │ │ └── CheckboxPT.js │ ├── PaperTrailRestoreView │ │ └── PaperTrailRestoreView.js │ ├── RelationField │ │ └── RelationField.js │ ├── RevisionForm │ │ └── RevisionForm.js │ ├── MediaCard │ │ └── MediaCard.js │ ├── PaperTrailReview │ │ └── PaperTrailReview.js │ ├── TrailTable │ │ └── TrailTable.js │ ├── RenderField │ │ └── RenderField.js │ ├── PaperTrail │ │ └── PaperTrail.js │ └── PaperTrailViewer │ │ └── PaperTrailViewer.js │ ├── pages │ ├── HomePage │ │ └── index.js │ └── App │ │ └── index.js │ └── index.js ├── server ├── utils │ ├── allowedStatuses.js │ ├── allowedMethods.js │ ├── entityName.js │ ├── matchApiPath.js │ ├── getChangeType.js │ ├── matchAdminPath.js │ ├── getPathParams.js │ ├── prepareTrailFromSchema.js │ ├── getContentTypeSchema.js │ └── checkContext.js ├── destroy.js ├── bootstrap.js ├── config │ └── index.js ├── content-types │ ├── index.js │ └── trail │ │ ├── user-permissions.js │ │ └── index.js ├── services │ ├── index.js │ └── paper-trail.js ├── middlewares │ ├── index.js │ └── paper-trail.js ├── index.js └── register.js ├── strapi-server.js ├── strapi-admin.js ├── .nova └── Tasks │ └── Node.json ├── .prettierrc ├── .eslintrc.json ├── tests ├── prepareTrailFromSchema.test.js ├── checkContext.test.js ├── getContentTypeSchema.test.js ├── matchApiPath.test.js ├── getPathParms.test.js ├── matchAdminPath.test.js ├── paper-trail-service.test.js └── mock-data.js ├── .github ├── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug-report.md └── workflows │ └── unit-test.yml ├── LICENSE ├── todo.md ├── package.json └── README.md /.nvmrc: -------------------------------------------------------------------------------- 1 | v20 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ -------------------------------------------------------------------------------- /admin/src/translations/fr.json: -------------------------------------------------------------------------------- 1 | {} -------------------------------------------------------------------------------- /server/utils/allowedStatuses.js: -------------------------------------------------------------------------------- 1 | module.exports = [200]; 2 | -------------------------------------------------------------------------------- /server/destroy.js: -------------------------------------------------------------------------------- 1 | module.exports = () => { 2 | // destroy phase 3 | }; 4 | -------------------------------------------------------------------------------- /server/utils/allowedMethods.js: -------------------------------------------------------------------------------- 1 | module.exports = ['POST', 'PUT','DELETE']; 2 | -------------------------------------------------------------------------------- /server/utils/entityName.js: -------------------------------------------------------------------------------- 1 | module.exports = 'plugin::paper-trail.trail'; 2 | -------------------------------------------------------------------------------- /server/bootstrap.js: -------------------------------------------------------------------------------- 1 | module.exports = () => { 2 | // bootstrap phase 3 | }; 4 | -------------------------------------------------------------------------------- /strapi-server.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = require('./server'); 4 | -------------------------------------------------------------------------------- /server/config/index.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | default: {}, 3 | validator() {} 4 | }; 5 | -------------------------------------------------------------------------------- /strapi-admin.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = require('./admin/src').default; 4 | -------------------------------------------------------------------------------- /server/content-types/index.js: -------------------------------------------------------------------------------- 1 | const trail = require('./trail'); 2 | 3 | module.exports = { 4 | trail 5 | }; 6 | -------------------------------------------------------------------------------- /server/services/index.js: -------------------------------------------------------------------------------- 1 | const paperTrailService = require('./paper-trail'); 2 | 3 | module.exports = { 4 | paperTrailService 5 | }; 6 | -------------------------------------------------------------------------------- /admin/src/utils/getTrad.js: -------------------------------------------------------------------------------- 1 | import pluginId from '../pluginId'; 2 | 3 | const getTrad = (id) => `${pluginId}.${id}`; 4 | 5 | export default getTrad; 6 | -------------------------------------------------------------------------------- /server/utils/matchApiPath.js: -------------------------------------------------------------------------------- 1 | module.exports = string => { 2 | const regex = /\/api\/[a-zA-Z0-9-](?:\/\d*)?/; 3 | return string.match(regex); 4 | }; 5 | -------------------------------------------------------------------------------- /.nova/Tasks/Node.json: -------------------------------------------------------------------------------- 1 | { 2 | "actions" : { 3 | "build" : { 4 | "enabled" : true, 5 | "script" : "npm run lint\nCI=true npm run test" 6 | } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /admin/src/pluginId.js: -------------------------------------------------------------------------------- 1 | import pluginPkg from '../../package.json'; 2 | 3 | const pluginId = pluginPkg.name.replace(/^(@[^-,.][\w,-]+\/|strapi-)plugin-/i, ''); 4 | 5 | export default pluginId; 6 | -------------------------------------------------------------------------------- /server/utils/getChangeType.js: -------------------------------------------------------------------------------- 1 | module.exports = method => { 2 | const changeTypes = { 3 | POST: 'CREATE', 4 | PUT: 'UPDATE', 5 | DELETE: 'DELETE' 6 | }; 7 | return changeTypes[method]; 8 | }; 9 | -------------------------------------------------------------------------------- /server/content-types/trail/user-permissions.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | users_permissions_user: { 3 | type: 'relation', 4 | relation: 'oneToOne', 5 | target: 'plugin::users-permissions.user' 6 | } 7 | }; 8 | -------------------------------------------------------------------------------- /server/utils/matchAdminPath.js: -------------------------------------------------------------------------------- 1 | module.exports = string => { 2 | const regex = 3 | /\/content-manager\/(collection-types|single-types)\/([a-zA-Z0-9-]+::[a-zA-Z0-9-]+\.[a-zA-Z0-9-]+)(?:\/\d*)?/; 4 | return string.match(regex); 5 | }; 6 | -------------------------------------------------------------------------------- /admin/src/components/PluginIcon/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 3 | * PluginIcon 4 | * 5 | */ 6 | 7 | import React from 'react'; 8 | import { Puzzle } from '@strapi/icons'; 9 | 10 | const PluginIcon = () => ; 11 | 12 | export default PluginIcon; 13 | -------------------------------------------------------------------------------- /server/middlewares/index.js: -------------------------------------------------------------------------------- 1 | const paperTrailMiddleware = require('./paper-trail'); 2 | 3 | /** 4 | * TODO: There may be a smarter way of doing this but it was a good learning experience - https://github.com/strapi/strapi/blob/main/packages/plugins/i18n/server/register.js 5 | */ 6 | 7 | module.exports = { 8 | paperTrailMiddleware 9 | }; 10 | -------------------------------------------------------------------------------- /admin/src/pages/HomePage/index.js: -------------------------------------------------------------------------------- 1 | /* 2 | * 3 | * HomePage 4 | * 5 | */ 6 | 7 | import React from 'react'; 8 | // import PropTypes from 'prop-types'; 9 | import pluginId from '../../pluginId'; 10 | 11 | const HomePage = () => { 12 | return ( 13 |
14 |

{pluginId}'s HomePage

15 |

Happy coding

16 |
17 | ); 18 | }; 19 | 20 | export default HomePage; 21 | -------------------------------------------------------------------------------- /admin/src/utils/getPaginationList.js: -------------------------------------------------------------------------------- 1 | export default function getPaginationList(page, pageCount) { 2 | if (pageCount <= 3 || page === 1) { 3 | return [1, pageCount > 1 ? 2 : 0, pageCount > 2 ? 3 : 0].filter(Boolean); 4 | } 5 | 6 | if (page === pageCount) { 7 | return [pageCount - 2, pageCount - 1, pageCount].filter(Boolean); 8 | } 9 | 10 | return [page - 1, page, page + 1 <= pageCount ? page + 1 : 0].filter(Boolean); 11 | } 12 | -------------------------------------------------------------------------------- /admin/src/utils/buildPayload.js: -------------------------------------------------------------------------------- 1 | export default function buildPayload(trimmedContent, revisedFields) { 2 | let changePayloadObj = {}; 3 | 4 | if (Object.keys(trimmedContent).length > 0 && revisedFields.length > 0) { 5 | revisedFields.map(field => { 6 | if (trimmedContent[field]) { 7 | changePayloadObj[field] = trimmedContent[field]; 8 | } 9 | 10 | return field; 11 | }); 12 | } 13 | 14 | return changePayloadObj; 15 | } 16 | -------------------------------------------------------------------------------- /server/index.js: -------------------------------------------------------------------------------- 1 | const register = require('./register'); 2 | const bootstrap = require('./bootstrap'); 3 | const destroy = require('./destroy'); 4 | const config = require('./config'); 5 | const contentTypes = require('./content-types'); 6 | const middlewares = require('./middlewares'); 7 | const services = require('./services'); 8 | 9 | module.exports = { 10 | register, 11 | bootstrap, 12 | destroy, 13 | config, 14 | services, 15 | contentTypes, 16 | middlewares 17 | }; 18 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "arrowParens": "avoid", 3 | "bracketSpacing": true, 4 | "jsxBracketSameLine": false, 5 | "printWidth": 80, 6 | "semi": true, 7 | "singleQuote": true, 8 | "tabWidth": 2, 9 | "trailingComma": "none", 10 | "useTabs": false, 11 | "importOrder": [ 12 | "^@core/(.*)$", 13 | "^@server/(.*)$", 14 | "^@ui/(.*)$", 15 | "^[./]", 16 | "^src/(.*)$" 17 | ], 18 | "importOrderSeparation": true, 19 | "importOrderSortSpecifiers": true 20 | } 21 | -------------------------------------------------------------------------------- /server/utils/getPathParams.js: -------------------------------------------------------------------------------- 1 | const { match } = require('path-to-regexp'); 2 | 3 | module.exports = (path, isAdmin) => { 4 | // take the path without the query string 5 | 6 | path = path.split('?')[0]; 7 | 8 | const matchFn = isAdmin 9 | ? match('/content-manager/:collectionType/:contentTypeName/:contentTypeId?') 10 | : match('/api/:contentTypeName/:contentTypeId?'); 11 | 12 | const matches = matchFn(path); 13 | 14 | const { params } = matches; 15 | 16 | return { ...params }; 17 | }; 18 | -------------------------------------------------------------------------------- /admin/src/utils/getUser.js: -------------------------------------------------------------------------------- 1 | function getUser(trail = {}) { 2 | const { admin_user, users_permissions_user } = trail; 3 | 4 | if (!admin_user && !users_permissions_user) { 5 | return 'Unknown'; 6 | } 7 | 8 | if (admin_user) { 9 | return [admin_user.firstname, admin_user.lastname, '(Admin)'] 10 | .filter(Boolean) 11 | .join(' '); 12 | } 13 | 14 | if (users_permissions_user) { 15 | return `${users_permissions_user.email} (User)`; 16 | } 17 | } 18 | 19 | export default getUser; 20 | -------------------------------------------------------------------------------- /admin/src/utils/returnFieldType.js: -------------------------------------------------------------------------------- 1 | export default function returnFieldType(type) { 2 | const validTypes = [ 3 | 'datetime', 4 | 'enumeration', 5 | 'email', 6 | 'integer', 7 | 'biginteger', 8 | 'decimal', 9 | 'float', 10 | 'media', 11 | 'richtext', 12 | 'relation', 13 | 'dynamiczone', 14 | 'json', 15 | 'boolean', 16 | 'component', 17 | 'text', 18 | 'string', 19 | 'uid' 20 | ]; 21 | 22 | return validTypes.includes(type) ? type : 'string'; 23 | } 24 | -------------------------------------------------------------------------------- /admin/src/components/Initializer/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 3 | * Initializer 4 | * 5 | */ 6 | 7 | import { useEffect, useRef } from 'react'; 8 | import PropTypes from 'prop-types'; 9 | import pluginId from '../../pluginId'; 10 | 11 | const Initializer = ({ setPlugin }) => { 12 | const ref = useRef(); 13 | ref.current = setPlugin; 14 | 15 | useEffect(() => { 16 | ref.current(pluginId); 17 | }, []); 18 | 19 | return null; 20 | }; 21 | 22 | Initializer.propTypes = { 23 | setPlugin: PropTypes.func.isRequired 24 | }; 25 | 26 | export default Initializer; 27 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": ["prettier"], 3 | "extends": ["react-app", "prettier"], 4 | "globals": { 5 | "strapi": true 6 | }, 7 | "rules": { 8 | "eqeqeq": ["error", "always"], 9 | "@typescript-eslint/no-unused-vars": "error", 10 | "quotes": [ 11 | "error", 12 | "single", 13 | { 14 | "avoidEscape": true 15 | } 16 | ], 17 | "react/jsx-curly-brace-presence": [ 18 | "error", 19 | { 20 | "props": "never", 21 | "children": "never", 22 | "propElementValues": "always" 23 | } 24 | ] 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /tests/prepareTrailFromSchema.test.js: -------------------------------------------------------------------------------- 1 | const { trail, schema } = require('./mock-data'); 2 | const prepareTrailFromSchema = require('../server/utils/prepareTrailFromSchema'); 3 | 4 | describe('utils: prepareTrailFromSchema', () => { 5 | it('should return a sanitized trail', async function () { 6 | const result = prepareTrailFromSchema(trail, schema); 7 | 8 | expect(result.trail.hasOwnProperty('someText')).toBe(true); 9 | expect(result.trail.hasOwnProperty('password')).toBe(false); 10 | expect(result.ignored.hasOwnProperty('id')).toBe(true); 11 | expect(result.ignored.hasOwnProperty('password')).toBe(true); 12 | }); 13 | }); 14 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: "[FEATURE]" 5 | labels: enhancement 6 | assignees: PenguinOfWar 7 | 8 | --- 9 | 10 | ## Is your feature request related to a problem? Please describe. 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | ## Describe the solution you'd like 14 | A clear and concise description of what you want to happen. 15 | 16 | ## Describe alternatives you've considered 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | ## Additional context 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /admin/src/pages/App/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 3 | * This component is the skeleton around the actual pages, and should only 4 | * contain code that should be seen on all pages. (e.g. navigation bar) 5 | * 6 | */ 7 | 8 | import React from 'react'; 9 | import { Switch, Route } from 'react-router-dom'; 10 | import { AnErrorOccurred } from '@strapi/helper-plugin'; 11 | import pluginId from '../../pluginId'; 12 | import HomePage from '../HomePage'; 13 | 14 | const App = () => { 15 | return ( 16 |
17 | 18 | 19 | 20 | 21 |
22 | ); 23 | }; 24 | 25 | export default App; 26 | -------------------------------------------------------------------------------- /tests/checkContext.test.js: -------------------------------------------------------------------------------- 1 | const { context, uid, schema } = require('./mock-data'); 2 | const checkContext = require('../server/utils/checkContext'); 3 | 4 | describe('utils: checkContext', () => { 5 | beforeEach(async function () { 6 | global.strapi = { 7 | contentTypes: { 8 | [uid]: schema 9 | } 10 | }; 11 | }); 12 | 13 | it('should parse the context and return useful fragments', async function () { 14 | const { schema, uid, isAdmin, change } = checkContext(context); 15 | 16 | expect(schema.kind).toBe('collectionType'); 17 | expect(schema.info.pluralName).toBe('another-types'); 18 | expect(uid).toBe('api::test-content-type.test-content-type'); 19 | expect(isAdmin).toBe(true); 20 | expect(change).toBe('UPDATE'); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /server/utils/prepareTrailFromSchema.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash'); 2 | module.exports = (update, schema) => { 3 | /** 4 | * Ignore the default strapi fields to focus on custom fields 5 | */ 6 | const ignoreProps = [ 7 | 'id', 8 | 'createdAt', 9 | 'updatedAt', 10 | 'createdBy', 11 | 'updatedBy', 12 | 'password' // For security 13 | ]; 14 | 15 | /** 16 | * Walk the update object and create our trail 17 | */ 18 | 19 | let trail = {}; 20 | let ignored = {}; 21 | if (!_.isEmpty(update)) { 22 | Object.keys(update).map(key => { 23 | if (schema.attributes.hasOwnProperty(key) && !ignoreProps.includes(key)) { 24 | trail[key] = update[key]; 25 | } else { 26 | ignored[key] = update[key]; 27 | } 28 | 29 | return key; 30 | }); 31 | } 32 | 33 | return { trail, ignored }; 34 | }; 35 | -------------------------------------------------------------------------------- /server/register.js: -------------------------------------------------------------------------------- 1 | const middlewares = require('./middlewares'); 2 | const userPermissionSchema = require('./content-types/trail/user-permissions'); 3 | 4 | module.exports = ({ strapi }) => { 5 | // during boot, check if the user-permissions plugin exists 6 | const userPermissionsContentType = strapi.contentType( 7 | 'plugin::users-permissions.user' 8 | ); 9 | 10 | if (userPermissionsContentType) { 11 | // if the user permissions plugin is installed, bind the trails directly to the user 12 | const trailContentType = strapi.contentType('plugin::paper-trail.trail'); 13 | 14 | trailContentType.attributes = { 15 | // Spread previous defined attributes 16 | ...trailContentType.attributes, 17 | // Add new attribute 18 | ...userPermissionSchema 19 | }; 20 | } 21 | 22 | strapi.server.use(middlewares.paperTrailMiddleware); 23 | }; 24 | -------------------------------------------------------------------------------- /server/utils/getContentTypeSchema.js: -------------------------------------------------------------------------------- 1 | module.exports = (contentTypeUid, isAdmin) => { 2 | const strapiContentTypes = strapi.contentTypes; 3 | 4 | if (isAdmin) { 5 | return strapiContentTypes[contentTypeUid]; 6 | } else { 7 | /** 8 | * If we don't already have the contentTypeUid from the admin panel we need to find it 9 | */ 10 | 11 | const entries = Object.entries(strapiContentTypes); 12 | 13 | const schema = entries.find(entry => { 14 | const innerEntry = entry[1]; 15 | 16 | if (!innerEntry) { 17 | return false; 18 | } 19 | 20 | if (innerEntry.info.pluralName === contentTypeUid) { 21 | return entry; 22 | } 23 | 24 | return false; 25 | }); 26 | 27 | if (!schema) { 28 | return false; 29 | } 30 | 31 | const innerSchema = schema[1]; 32 | 33 | return innerSchema; 34 | } 35 | }; 36 | -------------------------------------------------------------------------------- /tests/getContentTypeSchema.test.js: -------------------------------------------------------------------------------- 1 | const getContentTypeSchema = require('../server/utils/getContentTypeSchema'); 2 | const { uid, schema } = require('./mock-data'); 3 | 4 | describe('utils: getContentTypeSchema', () => { 5 | beforeEach(async function () { 6 | global.strapi = { 7 | contentTypes: { 8 | [uid]: schema 9 | } 10 | }; 11 | }); 12 | 13 | it('should return the correct admin schema object', async function () { 14 | const result = getContentTypeSchema(uid, 1); 15 | expect(result.kind).toBe('collectionType'); 16 | expect(result.collectionName).toBe('another_types'); 17 | }); 18 | 19 | it('should return the correct user schema object', async function () { 20 | const result = getContentTypeSchema('another-types', 0); 21 | 22 | expect(result.kind).toBe('collectionType'); 23 | expect(result.collectionName).toBe('another_types'); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /.github/workflows/unit-test.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: Unit Tests 5 | 6 | on: 7 | push: 8 | branches: [main] 9 | pull_request: 10 | branches: [main] 11 | 12 | jobs: 13 | build: 14 | runs-on: ubuntu-latest 15 | 16 | strategy: 17 | matrix: 18 | node-version: [14.x, 16.x, 18.x, 20.x] 19 | 20 | steps: 21 | - uses: actions/checkout@v3 22 | - name: Use Node.js ${{ matrix.node-version }} 23 | uses: actions/setup-node@v3 24 | with: 25 | node-version: ${{ matrix.node-version }} 26 | - run: npm install -g npm@9 27 | - run: npm ci 28 | - run: npm run lint 29 | - run: CI=true npm run test 30 | -------------------------------------------------------------------------------- /server/content-types/trail/index.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | schema: { 3 | kind: 'collectionType', 4 | collectionName: 'plugin_paper_trail_trails', 5 | info: { 6 | singularName: 'trail', 7 | pluralName: 'trails', 8 | displayName: 'Trail' 9 | }, 10 | options: { 11 | draftAndPublish: false, 12 | comment: '' 13 | }, 14 | pluginOptions: { 15 | 'content-manager': { 16 | visible: true 17 | }, 18 | 'content-type-builder': { 19 | visible: false 20 | } 21 | }, 22 | attributes: { 23 | recordId: { 24 | type: 'biginteger', 25 | required: true 26 | }, 27 | contentType: { 28 | type: 'string' 29 | }, 30 | version: { 31 | type: 'integer' 32 | }, 33 | change: { 34 | type: 'string' 35 | }, 36 | content: { 37 | type: 'json' 38 | }, 39 | admin_user: { 40 | type: 'relation', 41 | relation: 'oneToOne', 42 | target: 'admin::user' 43 | } 44 | } 45 | } 46 | }; 47 | -------------------------------------------------------------------------------- /server/middlewares/paper-trail.js: -------------------------------------------------------------------------------- 1 | const checkContext = require('../utils/checkContext'); 2 | 3 | module.exports = async (ctx, next) => { 4 | await next(); 5 | 6 | /** 7 | * Try/Catch so we don't totally mess with the admin panel if something is wrong 8 | */ 9 | 10 | try { 11 | const { uid, schema, isAdmin, change } = checkContext(ctx); 12 | 13 | if (!schema) { 14 | return; 15 | } 16 | 17 | /** 18 | * If we have a returned schema, check it for paperTrail.enabled 19 | */ 20 | 21 | const { pluginOptions } = schema; 22 | 23 | const enabled = pluginOptions?.paperTrail?.enabled; 24 | 25 | if (enabled) { 26 | /** 27 | * Intercept the body and take a snapshot of the change 28 | */ 29 | 30 | const paperTrailService = strapi 31 | .plugin('paper-trail') 32 | .service('paperTrailService'); 33 | 34 | await paperTrailService.createPaperTrail( 35 | ctx, 36 | schema, 37 | uid, 38 | change, 39 | isAdmin 40 | ); 41 | } 42 | } catch (Err) { 43 | console.warn('paper-trail: ', Err); 44 | } 45 | }; 46 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | ----------- 3 | 4 | Copyright (c) 2024 Darryl Walker 5 | 6 | Permission is hereby granted, free of charge, to any person 7 | obtaining a copy of this software and associated documentation 8 | files (the "Software"), to deal in the Software without 9 | restriction, including without limitation the rights to use, 10 | copy, modify, merge, publish, distribute, sublicense, and/or sell 11 | copies of the Software, and to permit persons to whom the 12 | Software is furnished to do so, subject to the following 13 | conditions: 14 | 15 | The above copyright notice and this permission notice shall be 16 | included in all copies or substantial portions of the Software. 17 | 18 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 19 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 20 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 21 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 22 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 23 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 24 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 25 | OTHER DEALINGS IN THE SOFTWARE. 26 | -------------------------------------------------------------------------------- /admin/src/components/MediaField/MediaField.js: -------------------------------------------------------------------------------- 1 | import { Box, Grid, Typography } from '@strapi/design-system'; 2 | import PropTypes from 'prop-types'; 3 | import React from 'react'; 4 | import { useIntl } from 'react-intl'; 5 | 6 | import getTrad from '../../utils/getTrad'; 7 | import MediaCard from '../MediaCard/MediaCard'; 8 | 9 | function MediaField(props) { 10 | const { media, attributes } = props; 11 | const { multiple } = attributes; 12 | 13 | const { formatMessage } = useIntl(); 14 | 15 | if (!media || media.length === 0) { 16 | return ( 17 | 18 | 19 | {formatMessage({ 20 | id: getTrad('plugin.admin.paperTrail.empty'), 21 | defaultMessage: 'Empty' 22 | })} 23 | 24 | 25 | ); 26 | } 27 | 28 | return ( 29 | 30 | {multiple ? ( 31 | media.map(item => ) 32 | ) : ( 33 | 34 | )} 35 | 36 | ); 37 | } 38 | 39 | MediaField.propTypes = { 40 | media: PropTypes.any, 41 | attributes: PropTypes.shape({ 42 | multiple: PropTypes.bool 43 | }) 44 | }; 45 | 46 | export default MediaField; 47 | -------------------------------------------------------------------------------- /tests/matchApiPath.test.js: -------------------------------------------------------------------------------- 1 | const matchApiPath = require('../server/utils/matchApiPath'); 2 | 3 | describe('utils: matchApiPath', () => { 4 | it('should match valid paths', async function () { 5 | const result1 = Boolean(matchApiPath('/api/another-type')); 6 | const result2 = Boolean(matchApiPath('/api/another-type/2')); 7 | const result3 = Boolean(matchApiPath('/api/another-type3')); 8 | const result4 = Boolean(matchApiPath('/api/another-type3/4')); 9 | const result5 = Boolean(matchApiPath('/api/another-type3/')); 10 | 11 | expect(result1).toBe(true); 12 | expect(result2).toBe(true); 13 | expect(result3).toBe(true); 14 | expect(result4).toBe(true); 15 | expect(result5).toBe(true); 16 | }); 17 | 18 | it('should reject invalid paths', async function () { 19 | const result1 = Boolean(matchApiPath('/some/path/another-type')); 20 | const result2 = Boolean( 21 | matchApiPath( 22 | '/content-manager/collection-types/api::another-type.another-type/2' 23 | ) 24 | ); 25 | const result3 = Boolean( 26 | matchApiPath( 27 | '/content-manager/collection-types/api::another-type.another-type' 28 | ) 29 | ); 30 | 31 | expect(result1).toBe(false); 32 | expect(result2).toBe(false); 33 | expect(result3).toBe(false); 34 | }); 35 | }); 36 | -------------------------------------------------------------------------------- /todo.md: -------------------------------------------------------------------------------- 1 | ### TODOs 2 | | Filename | line # | TODO | 3 | |:------|:------:|:------| 4 | | [admin/src/components/PaperTrail/PaperTrail.js](admin/src/components/PaperTrail/PaperTrail.js#L34) | 34 | add this to config/plugins.ts, needs a custom endpoint | 5 | | [admin/src/components/PaperTrail/PaperTrail.js](admin/src/components/PaperTrail/PaperTrail.js#L110) | 110 | this event listener is not working properly 100% of the time needs a better solution | 6 | | [admin/src/components/PaperTrail/PaperTrail.js](admin/src/components/PaperTrail/PaperTrail.js#L130) | 130 | Add diff comparison | 7 | | [admin/src/components/PaperTrail/PaperTrail.js](admin/src/components/PaperTrail/PaperTrail.js#L131) | 131 | Add up/down for changing UIDs and enabling/disabling plugin | 8 | | [admin/src/components/PaperTrailViewer/PaperTrailViewer.js](admin/src/components/PaperTrailViewer/PaperTrailViewer.js#L101) | 101 | Warning about changing content type/UID dropping trails from the admin panel / killing relationship | 9 | | [admin/src/components/RenderField/RenderField.js](admin/src/components/RenderField/RenderField.js#L120) | 120 | investigated a better way of managing this, and flag it in the readme as a risk | 10 | | [server/middlewares/index.js](server/middlewares/index.js#L6) | 6 | There may be a smarter way of doing this but it was a good learning experience - https://github.com/strapi/strapi/blob/main/packages/plugins/i18n/server/register.js | 11 | -------------------------------------------------------------------------------- /tests/getPathParms.test.js: -------------------------------------------------------------------------------- 1 | const getPathParams = require('../server/utils/getPathParams'); 2 | 3 | describe('utils: getPathParams', () => { 4 | it('should return params from valid admin paths', async function () { 5 | const result1 = getPathParams( 6 | '/content-manager/collection-types/foo-type', 7 | 1 8 | ); 9 | const result2 = getPathParams( 10 | '/content-manager/collection-types/foo-type/', 11 | 1 12 | ); 13 | const result3 = getPathParams( 14 | '/content-manager/collection-types/foo-type/42', 15 | 1 16 | ); 17 | const result4 = getPathParams( 18 | '/content-manager/collection-types/api::another-type.another-type/4?locale=en', 19 | 1 20 | ); 21 | 22 | expect(result1.contentTypeName).toBe('foo-type'); 23 | expect(result2.contentTypeName).toBe('foo-type'); 24 | expect(result3.contentTypeName).toBe('foo-type'); 25 | expect(result3.contentTypeId).toBe('42'); 26 | expect(result4.contentTypeName).toBe('api::another-type.another-type'); 27 | expect(result4.contentTypeId).toBe('4'); 28 | }); 29 | 30 | it('should return params from valid api paths', async function () { 31 | const result1 = getPathParams('/api/foo-type', 0); 32 | const result2 = getPathParams('/api/foo-type/', 0); 33 | const result3 = getPathParams('/api/foo-type/42', 0); 34 | 35 | expect(result1.contentTypeName).toBe('foo-type'); 36 | expect(result2.contentTypeName).toBe('foo-type'); 37 | expect(result3.contentTypeName).toBe('foo-type'); 38 | expect(result3.contentTypeId).toBe('42'); 39 | }); 40 | }); 41 | -------------------------------------------------------------------------------- /server/utils/checkContext.js: -------------------------------------------------------------------------------- 1 | const allowedMethods = require('./allowedMethods'); 2 | const allowedStatuses = require('./allowedStatuses'); 3 | const getContentTypeSchema = require('./getContentTypeSchema'); 4 | const getPathParams = require('./getPathParams'); 5 | const matchAdminPath = require('./matchAdminPath'); 6 | const matchApiPath = require('./matchApiPath'); 7 | const getChangeType = require('./getChangeType'); 8 | 9 | module.exports = context => { 10 | const { method, url } = context.request; 11 | const { status } = context.response; 12 | 13 | /** 14 | * We have a few things to check here. We're only interested in: 15 | * - POST | PUT | DELETE methods 16 | * - Routes that match a regex (admin content type endpoint or content type generated endpoint) 17 | */ 18 | 19 | const allowedStatusCheck = allowedStatuses.includes(status); 20 | const allowedMethodsCheck = allowedMethods.includes(method); 21 | const adminMatchCheck = Boolean(matchAdminPath(url)); 22 | const apiMatchCheck = Boolean(matchApiPath(url)); 23 | 24 | if ( 25 | allowedStatusCheck && 26 | allowedMethodsCheck && 27 | (adminMatchCheck || apiMatchCheck) 28 | ) { 29 | const params = getPathParams(url, adminMatchCheck); 30 | 31 | const { contentTypeName } = params; 32 | 33 | const schema = getContentTypeSchema(contentTypeName, adminMatchCheck); 34 | 35 | if (!schema) { 36 | return { contentTypeName: null, schema: null }; 37 | } 38 | 39 | const uid = schema.uid; 40 | const change = getChangeType(method); 41 | 42 | return { schema, uid, isAdmin: adminMatchCheck, change }; 43 | } 44 | 45 | return { contentTypeName: null, schema: null }; 46 | }; 47 | -------------------------------------------------------------------------------- /tests/matchAdminPath.test.js: -------------------------------------------------------------------------------- 1 | const matchAdminPath = require('../server/utils/matchAdminPath'); 2 | 3 | describe('utils: matchAdminPath', () => { 4 | it('should match valid paths', async function () { 5 | const result1 = Boolean( 6 | matchAdminPath( 7 | '/content-manager/collection-types/api::another-type.another-type' 8 | ) 9 | ); 10 | const result2 = Boolean( 11 | matchAdminPath( 12 | '/content-manager/collection-types/api::another-type.another-type/2' 13 | ) 14 | ); 15 | const result3 = Boolean( 16 | matchAdminPath( 17 | '/content-manager/collection-types/api::foo-type.bar-type/' 18 | ) 19 | ); 20 | const result4 = Boolean( 21 | matchAdminPath( 22 | '/content-manager/collection-types/api::foo-type.bar-type/1245' 23 | ) 24 | ); 25 | 26 | const result5 = Boolean( 27 | matchAdminPath('/content-manager/single-types/api::foo-type.bar-type/') 28 | ); 29 | 30 | const result6 = Boolean( 31 | matchAdminPath('/content-manager/single-types/api::foo-type.bar-type/4') 32 | ); 33 | 34 | expect(result1).toBe(true); 35 | expect(result2).toBe(true); 36 | expect(result3).toBe(true); 37 | expect(result4).toBe(true); 38 | expect(result5).toBe(true); 39 | expect(result6).toBe(true); 40 | }); 41 | 42 | it('should reject invalid paths', async function () { 43 | const result1 = Boolean(matchAdminPath('/api/foo-types')); 44 | const result2 = Boolean(matchAdminPath('/api/foo-types/')); 45 | const result3 = Boolean(matchAdminPath('/api/foo-types/4')); 46 | 47 | expect(result1).toBe(false); 48 | expect(result2).toBe(false); 49 | expect(result3).toBe(false); 50 | }); 51 | }); 52 | -------------------------------------------------------------------------------- /admin/src/components/TrailsTablePagination/TrailsTablePagination.js: -------------------------------------------------------------------------------- 1 | import { 2 | NextLink, 3 | PageLink, 4 | Pagination, 5 | PreviousLink 6 | } from '@strapi/design-system/v2'; 7 | import PropTypes from 'prop-types'; 8 | import React, { useCallback } from 'react'; 9 | 10 | import getPaginationList from '../../utils/getPaginationList'; 11 | 12 | function TrailsTablePagination(props) { 13 | const { page, pageCount, setPage } = props; 14 | 15 | const pageList = getPaginationList(page, pageCount); 16 | 17 | const handleClick = useCallback( 18 | (event, newPage) => { 19 | event.preventDefault(); 20 | if (page !== newPage) { 21 | setPage(newPage); 22 | } 23 | }, 24 | [page, setPage] 25 | ); 26 | 27 | return ( 28 | 29 | handleClick(event, page - 1)} 32 | > 33 | Go to previous page 34 | 35 | {pageList.map(pageNum => ( 36 | handleClick(event, pageNum)} 41 | > 42 | Go to page ${pageNum} 43 | 44 | ))} 45 | handleClick(event, page + 1)} 48 | > 49 | Go to next page 50 | 51 | 52 | ); 53 | } 54 | 55 | TrailsTablePagination.propTypes = { 56 | page: PropTypes.number.isRequired, 57 | total: PropTypes.number.isRequired, 58 | pageSize: PropTypes.number.isRequired, 59 | pageCount: PropTypes.number.isRequired, 60 | setPage: PropTypes.func.isRequired 61 | }; 62 | 63 | export default TrailsTablePagination; 64 | -------------------------------------------------------------------------------- /tests/paper-trail-service.test.js: -------------------------------------------------------------------------------- 1 | const paperTrailService = require('../server/services/paper-trail'); 2 | const { context, entityServiceResponse, uid } = require('./mock-data'); 3 | const { schema } = require('../server/content-types/trail'); 4 | 5 | describe('service: paper-trail - CREATE', () => { 6 | let strapi; 7 | beforeEach(async function () { 8 | strapi = { 9 | entityService: { 10 | create: jest.fn().mockReturnValue(entityServiceResponse('CREATE', 1)), 11 | findMany: jest.fn().mockReturnValue([]) 12 | } 13 | }; 14 | }); 15 | it('should create a new paper trail', async function () { 16 | const paperTrail = await paperTrailService({ strapi }).createPaperTrail( 17 | context, 18 | schema, 19 | uid, 20 | 'CREATE', 21 | false 22 | ); 23 | 24 | expect(strapi.entityService.findMany).toBeCalledTimes(1); 25 | expect(strapi.entityService.create).toBeCalledTimes(1); 26 | expect(paperTrail.change).toBe('CREATE'); 27 | expect(paperTrail.version).toBe(1); 28 | }); 29 | }); 30 | 31 | describe('service: paper-trail - UPDATE', () => { 32 | let strapi; 33 | beforeEach(async function () { 34 | strapi = { 35 | entityService: { 36 | create: jest.fn().mockReturnValue(entityServiceResponse('UPDATE', 4)), 37 | findMany: jest.fn().mockReturnValue(entityServiceResponse('UPDATE', 4)) 38 | } 39 | }; 40 | }); 41 | it('should create a new paper trail', async function () { 42 | const paperTrail = await paperTrailService({ strapi }).createPaperTrail( 43 | context, 44 | schema, 45 | uid, 46 | 'CREATE', 47 | false 48 | ); 49 | 50 | expect(strapi.entityService.findMany).toBeCalledTimes(1); 51 | expect(strapi.entityService.create).toBeCalledTimes(1); 52 | expect(paperTrail.change).toBe('UPDATE'); 53 | expect(paperTrail.version).toBe(4); 54 | }); 55 | }); 56 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug-report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: "[BUG]" 5 | labels: bug 6 | assignees: PenguinOfWar 7 | 8 | --- 9 | 10 | 22 | 23 | ## Bug report 24 | 25 | ### Required System information 26 | 27 | 28 | 29 | 30 | 31 | 32 | - Node.js version: 33 | - NPM version: 34 | - Strapi version: 35 | - Database: 36 | - Operating system: 37 | - Is your project Javascript or Typescript: 38 | 39 | 40 | 41 | ### Describe the bug 42 | 43 | A clear and concise description of what the bug is. 44 | 45 | ### Steps to reproduce the behavior 46 | 47 | 1. Go to '...' 48 | 2. Click on '....' 49 | 3. Scroll down to '....' 50 | 4. See error 51 | 52 | ### Expected behavior 53 | 54 | A clear and concise description of what you expected to happen. 55 | 56 | ### Screenshots 57 | 58 | If applicable, add screenshots to help explain your problem. 59 | 60 | ### Code snippets 61 | 62 | If applicable, add code samples to help explain your problem. 63 | 64 | ### Additional context 65 | 66 | Add any other context about the problem here. 67 | -------------------------------------------------------------------------------- /admin/src/components/CheckboxPT/CheckboxPT.js: -------------------------------------------------------------------------------- 1 | import { Checkbox } from '@strapi/design-system'; 2 | import PropTypes from 'prop-types'; 3 | import React from 'react'; 4 | import { useIntl } from 'react-intl'; 5 | 6 | function CheckboxPT({ 7 | description, 8 | isCreating, 9 | intlLabel, 10 | name, 11 | onChange, 12 | value 13 | }) { 14 | const { formatMessage } = useIntl(); 15 | 16 | const handleChange = value => { 17 | if (isCreating || value) { 18 | return onChange({ target: { name, value, type: 'checkbox' } }); 19 | } 20 | 21 | return onChange({ target: { name, value: false, type: 'checkbox' } }); 22 | }; 23 | 24 | const label = intlLabel.id 25 | ? formatMessage( 26 | { id: intlLabel.id, defaultMessage: intlLabel.defaultMessage }, 27 | { ...intlLabel.values } 28 | ) 29 | : name; 30 | 31 | const hint = description 32 | ? formatMessage( 33 | { id: description.id, defaultMessage: description.defaultMessage }, 34 | { ...description.values } 35 | ) 36 | : ''; 37 | 38 | return ( 39 | 47 | {label} 48 | 49 | ); 50 | } 51 | 52 | CheckboxPT.defaultProps = { 53 | description: null, 54 | isCreating: false 55 | }; 56 | 57 | CheckboxPT.propTypes = { 58 | description: PropTypes.shape({ 59 | id: PropTypes.string.isRequired, 60 | defaultMessage: PropTypes.string.isRequired, 61 | values: PropTypes.object 62 | }), 63 | intlLabel: PropTypes.shape({ 64 | id: PropTypes.string.isRequired, 65 | defaultMessage: PropTypes.string.isRequired, 66 | values: PropTypes.object 67 | }).isRequired, 68 | isCreating: PropTypes.bool, 69 | name: PropTypes.string.isRequired, 70 | onChange: PropTypes.func.isRequired, 71 | value: PropTypes.bool.isRequired 72 | }; 73 | 74 | export default CheckboxPT; 75 | -------------------------------------------------------------------------------- /server/services/paper-trail.js: -------------------------------------------------------------------------------- 1 | const prepareTrailFromSchema = require('../utils/prepareTrailFromSchema'); 2 | const entityName = require('../utils/entityName'); 3 | 4 | module.exports = ({ strapi }) => ({ 5 | async createPaperTrail(context, schema, uid, change, isAdmin) { 6 | const body = isAdmin ? context.request.body : context.request.body.data; 7 | const user = context.state.user; 8 | const userId = user?.id; 9 | const resBody = isAdmin 10 | ? context.response.body 11 | : context.response.body.data; 12 | 13 | const id = resBody.id || resBody?.data?.id; 14 | 15 | /** 16 | * Early return, if we don't have a record ID for existing or newly created record the trail is useless 17 | */ 18 | 19 | if (!id) { 20 | return; 21 | } 22 | 23 | const { trail } = prepareTrailFromSchema(body, schema); 24 | 25 | /** 26 | * Get all trails belonging to this reecord so we can increment a version number 27 | */ 28 | 29 | const trails = await strapi.entityService.findMany(entityName, { 30 | fields: ['version'], 31 | filters: { contentType: uid, recordId: id }, 32 | sort: { version: 'DESC' } 33 | }); 34 | 35 | let version = trails[0] ? trails[0].version + 1 : 1; 36 | 37 | /** 38 | * build our new trail record 39 | */ 40 | 41 | const newTrail = { 42 | admin_user: { 43 | connect: isAdmin && userId ? [{ id: userId }] : [], 44 | disconnect: [] 45 | }, 46 | change, 47 | content: trail, 48 | contentType: uid, 49 | recordId: id, 50 | users_permissions_user: { 51 | connect: !isAdmin && userId ? [{ id: userId }] : [], 52 | disconnect: [] 53 | }, 54 | version 55 | }; 56 | 57 | /** 58 | * Save it 59 | */ 60 | 61 | try { 62 | const entity = await strapi.entityService.create(entityName, { 63 | data: newTrail 64 | }); 65 | 66 | return entity; 67 | } catch (Err) { 68 | console.warn('paper-trail: ', Err); 69 | } 70 | 71 | return trail; 72 | } 73 | }); 74 | -------------------------------------------------------------------------------- /tests/mock-data.js: -------------------------------------------------------------------------------- 1 | const trail = { 2 | id: 5, 3 | someText: 'Foobar123456123123123', 4 | publishedAt: '2023-06-05T15:38:30.608Z', 5 | locale: 'en', 6 | localizations: [4], 7 | password: 'foo123' 8 | }; 9 | 10 | const entityServiceResponse = (change, version) => { 11 | return { 12 | id: 5, 13 | recordId: '2', 14 | contentType: 'api::another-type.another-type', 15 | version, 16 | change, 17 | content: { 18 | locale: 'en', 19 | someText: 'Foobar123456123123123', 20 | publishedAt: '2023-06-05T15:38:30.608Z', 21 | localizations: [4] 22 | }, 23 | createdAt: '2023-06-06T09:22:08.025Z', 24 | updatedAt: '2023-06-06T09:22:08.025Z' 25 | }; 26 | }; 27 | 28 | module.exports = { 29 | entityServiceResponse: (change, version) => 30 | entityServiceResponse(change, version), 31 | trail, 32 | context: { 33 | state: { 34 | user: { 35 | id: 1 36 | } 37 | }, 38 | request: { 39 | method: 'PUT', 40 | url: '/content-manager/collection-types/api::another-type.another-type/2', 41 | body: { 42 | ...trail, 43 | data: { 44 | ...trail 45 | } 46 | } 47 | }, 48 | response: { 49 | status: 200, 50 | message: 'OK', 51 | body: { 52 | ...trail, 53 | data: { 54 | ...trail 55 | } 56 | } 57 | } 58 | }, 59 | uid: 'api::another-type.another-type', 60 | schema: { 61 | uid: 'api::test-content-type.test-content-type', // Not present on the static schema, added by strapi at runtime 62 | kind: 'collectionType', 63 | collectionName: 'another_types', 64 | info: { 65 | singularName: 'another-type', 66 | pluralName: 'another-types', 67 | displayName: 'AnotherType', 68 | description: '' 69 | }, 70 | options: { 71 | draftAndPublish: true 72 | }, 73 | pluginOptions: { 74 | paperTrail: { 75 | enabled: true 76 | }, 77 | i18n: { 78 | localized: true 79 | } 80 | }, 81 | attributes: { 82 | someText: { 83 | type: 'string', 84 | pluginOptions: { 85 | i18n: { 86 | localized: true 87 | } 88 | } 89 | }, 90 | password: { 91 | type: 'password' 92 | } 93 | } 94 | } 95 | }; 96 | -------------------------------------------------------------------------------- /admin/src/translations/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "plugin.schema.paperTrail.label-content-type": "Paper Trail", 3 | "plugin.schema.paperTrail.description-content-type": "Enable Paper Trail auditing and content versioning for this content type", 4 | "plugin.admin.paperTrail.title": "Paper Trail", 5 | "plugin.admin.paperTrail.noTrails": "No versions (yet)", 6 | "plugin.admin.paperTrail.currentVersion": "Current version:", 7 | "plugin.admin.paperTrail.created": "Created:", 8 | "plugin.admin.paperTrail.createdBy": "Created by:", 9 | "plugin.admin.paperTrail.createdByNaked": "Created By", 10 | "plugin.admin.paperTrail.viewAll": "View all", 11 | "plugin.admin.paperTrail.close": "Close", 12 | "plugin.admin.paperTrail.revisionHistory": "Revision History", 13 | "plugin.admin.paperTrail.id": "ID", 14 | "plugin.admin.paperTrail.contentType": "Content Type", 15 | "plugin.admin.paperTrail.createdNaked": "Created", 16 | "plugin.admin.paperTrail.actions": "Actions", 17 | "plugin.admin.paperTrail.version": "Version", 18 | "plugin.admin.paperTrail.viewVersion": "View version", 19 | "plugin.admin.paperTrail.back": "Back", 20 | "plugin.admin.paperTrail.changeType": "Change Type", 21 | "plugin.admin.paperTrail.by": "by", 22 | "plugin.admin.paperTrail.selectRevision": "Select revision", 23 | "plugin.admin.paperTrail.timePicker": "Time picker", 24 | "plugin.admin.paperTrail.asString": "As string", 25 | "plugin.admin.paperTrail.string": "String", 26 | "plugin.admin.paperTrail.number": "Number", 27 | "plugin.admin.paperTrail.text": "Text", 28 | "plugin.admin.paperTrail.true": "True", 29 | "plugin.admin.paperTrail.false": "False", 30 | "plugin.admin.paperTrail.empty": "Empty", 31 | "plugin.admin.paperTrail.mediaNotFound": "Media not found", 32 | "plugin.admin.paperTrail.media": "Media", 33 | "plugin.admin.paperTrail.connect": "Connect", 34 | "plugin.admin.paperTrail.disconnect": "Disconnect", 35 | "plugin.admin.paperTrail.review": "Review", 36 | "plugin.admin.paperTrail.reviewChanges": "Review changes", 37 | "plugin.admin.paperTrail.reviewChangesDescription": "Review the below changes carefully. Upon clicking 'Restore' the record will be instantly updated with the selected values.", 38 | "plugin.admin.paperTrail.viewRawJson": "View JSON", 39 | "plugin.admin.paperTrail.revisionExplanation": "Select properties from the below list to restore from this revision. You will have the chance to review before committing.", 40 | "plugin.admin.paperTrail.restore": "Restore", 41 | "plugin.admin.paperTrail.error": "Error" 42 | } 43 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "strapi-plugin-paper-trail", 3 | "version": "0.6.1", 4 | "description": "Accountability and content versioning for strapi v4+", 5 | "homepage": "https://github.com/PenguinOfWar/strapi-plugin-paper-trail#readme", 6 | "bugs": { 7 | "url": "https://github.com/PenguinOfWar/strapi-plugin-paper-trail/issues" 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "git+https://github.com/PenguinOfWar/strapi-plugin-paper-trail.git" 12 | }, 13 | "license": "MIT", 14 | "author": { 15 | "name": "Darryl Walker", 16 | "email": "darryljwalker@me.com" 17 | }, 18 | "maintainers": [ 19 | { 20 | "name": "Darryl Walker" 21 | } 22 | ], 23 | "contributors": [ 24 | { 25 | "name": "Brianggalvez", 26 | "url": "https://github.com/Brianggalvez" 27 | }, 28 | { 29 | "name": "robsonpiere", 30 | "url": "https://github.com/robsonpiere" 31 | }, 32 | { 33 | "name": "redondi88", 34 | "url": "https://github.com/redondi88" 35 | } 36 | ], 37 | "scripts": { 38 | "lint": "eslint admin/src server", 39 | "test": "jest", 40 | "todo": "leasot -x --reporter markdown '{admin,server}/**/*' --ignore '**/*.+(jpg|jpeg|gif|png|svg|json)' > todo.md" 41 | }, 42 | "dependencies": { 43 | "@strapi/design-system": "^1.6.3", 44 | "@strapi/helper-plugin": "^4.16.2", 45 | "@strapi/icons": "^1.6.3", 46 | "path-to-regexp": "^6.2.1", 47 | "prop-types": "^15.7.2" 48 | }, 49 | "devDependencies": { 50 | "@babel/plugin-proposal-private-property-in-object": "^7.21.11", 51 | "@trivago/prettier-plugin-sort-imports": "^4.3.0", 52 | "eslint": "^8.56.0", 53 | "eslint-config-prettier": "^9.1.0", 54 | "eslint-config-react-app": "^7.0.1", 55 | "eslint-plugin-prettier": "^5.1.3", 56 | "jest": "^29.5.0", 57 | "leasot": "^13.3.0", 58 | "prettier": "^3.2.5", 59 | "react": "^18.2.0", 60 | "react-dom": "^18.2.0", 61 | "react-router-dom": "^5.3.4", 62 | "styled-components": "^5.3.6" 63 | }, 64 | "peerDependencies": { 65 | "@strapi/strapi": "^4.16.2", 66 | "react": "^17.0.0 || ^18.0.0", 67 | "react-dom": "^17.0.0 || ^18.0.0", 68 | "react-router-dom": "^5.3.4", 69 | "styled-components": "^5.3.3" 70 | }, 71 | "engines": { 72 | "node": ">=14.19.1 <=20.x.x", 73 | "npm": ">=6.0.0" 74 | }, 75 | "strapi": { 76 | "name": "paper-trail", 77 | "description": "Accountability and content versioning for strapi v4+", 78 | "kind": "plugin", 79 | "displayName": "Paper Trail" 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /admin/src/translations/pt-BR.json: -------------------------------------------------------------------------------- 1 | { 2 | "plugin.schema.paperTrail.label-content-type": "Paper Trail", 3 | "plugin.schema.paperTrail.description-content-type": "Ativar auditoria de Paper Trail e versionamento de conteúdo para este tipo de conteúdo", 4 | "plugin.admin.paperTrail.title": "Paper Trail", 5 | "plugin.admin.paperTrail.noTrails": "Nenhuma versão (ainda)", 6 | "plugin.admin.paperTrail.currentVersion": "Versão atual:", 7 | "plugin.admin.paperTrail.created": "Criado:", 8 | "plugin.admin.paperTrail.createdBy": "Criado por:", 9 | "plugin.admin.paperTrail.createdByNaked": "Criado por", 10 | "plugin.admin.paperTrail.viewAll": "Ver todos", 11 | "plugin.admin.paperTrail.close": "Fechar", 12 | "plugin.admin.paperTrail.revisionHistory": "Histórico de revisões", 13 | "plugin.admin.paperTrail.id": "ID", 14 | "plugin.admin.paperTrail.contentType": "Tipo de Conteúdo", 15 | "plugin.admin.paperTrail.createdNaked": "Criado", 16 | "plugin.admin.paperTrail.actions": "Ações", 17 | "plugin.admin.paperTrail.version": "Versão", 18 | "plugin.admin.paperTrail.viewVersion": "Ver versão", 19 | "plugin.admin.paperTrail.back": "Voltar", 20 | "plugin.admin.paperTrail.changeType": "Tipo de Mudança", 21 | "plugin.admin.paperTrail.by": "por", 22 | "plugin.admin.paperTrail.selectRevision": "Selecionar revisão", 23 | "plugin.admin.paperTrail.timePicker": "Seletor de tempo", 24 | "plugin.admin.paperTrail.asString": "Como string", 25 | "plugin.admin.paperTrail.string": "String", 26 | "plugin.admin.paperTrail.number": "Número", 27 | "plugin.admin.paperTrail.text": "Texto", 28 | "plugin.admin.paperTrail.true": "Verdadeiro", 29 | "plugin.admin.paperTrail.false": "Falso", 30 | "plugin.admin.paperTrail.empty": "Vazio", 31 | "plugin.admin.paperTrail.mediaNotFound": "Mídia não encontrada", 32 | "plugin.admin.paperTrail.media": "Mídia", 33 | "plugin.admin.paperTrail.connect": "Conectar", 34 | "plugin.admin.paperTrail.disconnect": "Desconectar", 35 | "plugin.admin.paperTrail.review": "Revisar", 36 | "plugin.admin.paperTrail.reviewChanges": "Revisar mudanças", 37 | "plugin.admin.paperTrail.reviewChangesDescription": "Revise cuidadosamente as mudanças abaixo. Ao clicar em 'Restaurar', o registro será atualizado instantaneamente com os valores selecionados.", 38 | "plugin.admin.paperTrail.viewRawJson": "Ver JSON", 39 | "plugin.admin.paperTrail.revisionExplanation": "Selecione propriedades da lista abaixo para restaurar desta revisão. Você terá a chance de revisar antes de confirmar.", 40 | "plugin.admin.paperTrail.restore": "Restaurar", 41 | "plugin.admin.paperTrail.error": "Erro" 42 | } 43 | -------------------------------------------------------------------------------- /admin/src/index.js: -------------------------------------------------------------------------------- 1 | import { prefixPluginTranslations } from '@strapi/helper-plugin'; 2 | import * as yup from 'yup'; 3 | 4 | import pluginPkg from '../../package.json'; 5 | import CheckboxPT from './components/CheckboxPT/CheckboxPT'; 6 | import Initializer from './components/Initializer'; 7 | import PaperTrail from './components/PaperTrail/PaperTrail'; 8 | import pluginId from './pluginId'; 9 | import getTrad from './utils/getTrad'; 10 | 11 | const name = pluginPkg.strapi.name; 12 | 13 | const App = { 14 | register(app) { 15 | app.registerPlugin({ 16 | id: pluginId, 17 | initializer: Initializer, 18 | isReady: false, 19 | name 20 | }); 21 | }, 22 | 23 | bootstrap(app) { 24 | app.injectContentManagerComponent('editView', 'right-links', { 25 | name: 'Paper Trail', 26 | Component: PaperTrail 27 | }); 28 | 29 | const ctbPlugin = app.getPlugin('content-type-builder'); 30 | 31 | if (ctbPlugin) { 32 | const ctbFormsAPI = ctbPlugin.apis.forms; 33 | 34 | ctbFormsAPI.components.add({ 35 | id: 'pluginPaperTrailCheckboxConfirmation', 36 | component: CheckboxPT 37 | }); 38 | 39 | ctbFormsAPI.extendContentType({ 40 | validator: () => ({ 41 | paperTrail: yup.object().shape({ 42 | enabled: yup.bool() 43 | }) 44 | }), 45 | form: { 46 | advanced() { 47 | return [ 48 | { 49 | name: 'pluginOptions.paperTrail.enabled', 50 | description: { 51 | id: getTrad( 52 | 'plugin.schema.paperTrail.description-content-type' 53 | ), 54 | defaultMessage: 55 | 'Enable Paper Trail auditing and content versioning for this content type' 56 | }, 57 | type: 'pluginPaperTrailCheckboxConfirmation', 58 | intlLabel: { 59 | id: getTrad('plugin.schema.paperTrail.label-content-type'), 60 | defaultMessage: 'Paper Trail' 61 | } 62 | } 63 | ]; 64 | } 65 | } 66 | }); 67 | } 68 | }, 69 | async registerTrads({ locales }) { 70 | const importedTrads = await Promise.all( 71 | locales.map(locale => { 72 | return import( 73 | /* webpackChunkName: "pt-translation-[request]" */ `./translations/${locale}.json` 74 | ) 75 | .then(({ default: data }) => { 76 | return { 77 | data: prefixPluginTranslations(data, pluginId), 78 | locale 79 | }; 80 | }) 81 | .catch(() => { 82 | return { 83 | data: {}, 84 | locale 85 | }; 86 | }); 87 | }) 88 | ); 89 | 90 | return Promise.resolve(importedTrads); 91 | } 92 | }; 93 | 94 | export default App; 95 | -------------------------------------------------------------------------------- /admin/src/components/PaperTrailRestoreView/PaperTrailRestoreView.js: -------------------------------------------------------------------------------- 1 | import { BaseHeaderLayout, Box, Divider, Link } from '@strapi/design-system'; 2 | import { ArrowLeft } from '@strapi/icons'; 3 | import { format, parseISO } from 'date-fns'; 4 | import PropTypes from 'prop-types'; 5 | import React, { Fragment } from 'react'; 6 | import { useIntl } from 'react-intl'; 7 | 8 | import getTrad from '../../utils/getTrad'; 9 | import getUser from '../../utils/getUser'; 10 | import RevisionForm from '../RevisionForm/RevisionForm'; 11 | 12 | function PaperTrailRestoreView(props) { 13 | const { trail, setViewRevision, setRevisedFields } = props; 14 | 15 | const { formatMessage } = useIntl(); 16 | 17 | return ( 18 | 19 | 20 | } 25 | onClick={event => { 26 | event.preventDefault(); 27 | setViewRevision(null); 28 | }} 29 | > 30 | {formatMessage({ 31 | id: getTrad('plugin.admin.paperTrail.back'), 32 | defaultMessage: 'Back' 33 | })} 34 | 35 | } 36 | title={`${formatMessage({ 37 | id: getTrad('plugin.admin.paperTrail.version'), 38 | defaultMessage: 'Version' 39 | })} ${trail.version}`} 40 | subtitle={`${formatMessage({ 41 | id: getTrad('plugin.admin.paperTrail.id'), 42 | defaultMessage: 'ID' 43 | })}: ${trail.recordId} | ${trail.change} | ${format( 44 | parseISO(trail.createdAt), 45 | 'MMM d, yyyy HH:mm' 46 | )} ${formatMessage({ 47 | id: getTrad('plugin.admin.paperTrail.by'), 48 | defaultMessage: 'by' 49 | })} ${getUser(trail)}`} 50 | as="h3" 51 | /> 52 | 53 | 54 | 55 | 56 | 65 | 66 | 67 | 68 | ); 69 | } 70 | 71 | PaperTrailRestoreView.propTypes = { 72 | setViewRevision: PropTypes.func.isRequired, 73 | setRevisedFields: PropTypes.func.isRequired, 74 | trail: PropTypes.shape({ 75 | change: PropTypes.string, 76 | content: PropTypes.object, 77 | contentType: PropTypes.string, 78 | createdAt: PropTypes.string, 79 | id: PropTypes.number, 80 | recordId: PropTypes.string, 81 | updatedAt: PropTypes.string 82 | }) 83 | }; 84 | 85 | export default PaperTrailRestoreView; 86 | -------------------------------------------------------------------------------- /admin/src/components/RelationField/RelationField.js: -------------------------------------------------------------------------------- 1 | import { Box, Typography } from '@strapi/design-system'; 2 | import PropTypes from 'prop-types'; 3 | import React, { Fragment } from 'react'; 4 | import { useIntl } from 'react-intl'; 5 | 6 | import getTrad from '../../utils/getTrad'; 7 | 8 | function RelationField(props) { 9 | const { relation, attributes } = props; 10 | 11 | const { connect, disconnect } = relation; 12 | 13 | const { target } = attributes; 14 | 15 | const { formatMessage } = useIntl(); 16 | 17 | return ( 18 | 19 | 20 | 21 | {formatMessage({ 22 | id: getTrad('plugin.admin.paperTrail.connect'), 23 | defaultMessage: 'Connect' 24 | })} 25 | 26 | {connect && connect.length > 0 ? ( 27 |
    28 | {connect.map(item => ( 29 |
  • 30 | 31 | {target}: {item.id} 32 | 33 |
  • 34 | ))} 35 |
36 | ) : ( 37 | 38 | 39 | {formatMessage({ 40 | id: getTrad('plugin.admin.paperTrail.empty'), 41 | defaultMessage: 'Empty' 42 | })} 43 | 44 | 45 | )} 46 |
47 | 48 | 49 | {formatMessage({ 50 | id: getTrad('plugin.admin.paperTrail.disconnect'), 51 | defaultMessage: 'Disconnect' 52 | })} 53 | 54 | {disconnect && disconnect.length > 0 ? ( 55 |
    56 | {disconnect.map(item => ( 57 |
  • 58 | 59 | {target}: {item.id} 60 | 61 |
  • 62 | ))} 63 |
64 | ) : ( 65 | 66 | 67 | {formatMessage({ 68 | id: getTrad('plugin.admin.paperTrail.empty'), 69 | defaultMessage: 'Empty' 70 | })} 71 | 72 | 73 | )} 74 |
75 |
76 | ); 77 | } 78 | 79 | RelationField.propTypes = { 80 | relation: PropTypes.shape({ 81 | connect: PropTypes.arrayOf( 82 | PropTypes.shape({ 83 | id: PropTypes.number 84 | }) 85 | ), 86 | disconnect: PropTypes.arrayOf( 87 | PropTypes.shape({ 88 | id: PropTypes.number 89 | }) 90 | ) 91 | }), 92 | attributes: PropTypes.shape({ 93 | target: PropTypes.string 94 | }) 95 | }; 96 | 97 | export default RelationField; 98 | -------------------------------------------------------------------------------- /admin/src/components/RevisionForm/RevisionForm.js: -------------------------------------------------------------------------------- 1 | import { 2 | Accordion, 3 | AccordionContent, 4 | AccordionToggle, 5 | Box, 6 | JSONInput, 7 | Typography 8 | } from '@strapi/design-system'; 9 | import { useCMEditViewDataManager } from '@strapi/helper-plugin'; 10 | import PropTypes from 'prop-types'; 11 | import React, { Fragment, useState } from 'react'; 12 | import { useIntl } from 'react-intl'; 13 | 14 | import prepareTrailFromSchema from '../../../../server/utils/prepareTrailFromSchema'; 15 | import getTrad from '../../utils/getTrad'; 16 | import RenderField from '../RenderField/RenderField'; 17 | 18 | function RevisionForm(props) { 19 | const { trail, setRevisedFields } = props; 20 | 21 | const { content } = trail; 22 | 23 | const { layout } = useCMEditViewDataManager(); 24 | 25 | const { formatMessage } = useIntl(); 26 | const [expanded, setExpanded] = useState(false); 27 | 28 | /** 29 | * trim ignored props and anything not in the current schema 30 | */ 31 | 32 | const { trail: trimmedContent } = prepareTrailFromSchema(content, layout); 33 | 34 | return ( 35 | 36 | 37 | 38 | {formatMessage({ 39 | id: getTrad('plugin.admin.paperTrail.revisionExplanation'), 40 | defaultMessage: 41 | 'Select properties from the below list to restore from this revision. You will have the chance to review before committing.' 42 | })} 43 | 44 | 45 |
46 | {Object.keys(trimmedContent).map(key => ( 47 | 53 | ))} 54 | 55 | {/* raw json */} 56 | 57 | setExpanded(s => !s)} 60 | id="acc-field-pt-raw" 61 | > 62 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 |
77 | ); 78 | } 79 | 80 | RevisionForm.propTypes = { 81 | setRevisedFields: PropTypes.func.isRequired, 82 | trail: PropTypes.shape({ 83 | change: PropTypes.string, 84 | content: PropTypes.object, 85 | contentType: PropTypes.string, 86 | createdAt: PropTypes.string, 87 | id: PropTypes.number, 88 | recordId: PropTypes.string, 89 | updatedAt: PropTypes.string 90 | }) 91 | }; 92 | 93 | export default RevisionForm; 94 | -------------------------------------------------------------------------------- /admin/src/components/MediaCard/MediaCard.js: -------------------------------------------------------------------------------- 1 | import { 2 | Box, 3 | Card, 4 | CardAsset, 5 | CardBadge, 6 | CardBody, 7 | CardContent, 8 | CardHeader, 9 | CardSubtitle, 10 | CardTitle, 11 | GridItem, 12 | Loader, 13 | Typography 14 | } from '@strapi/design-system'; 15 | import { useFetchClient } from '@strapi/helper-plugin'; 16 | import { Picture } from '@strapi/icons'; 17 | import PropTypes from 'prop-types'; 18 | import React, { useEffect, useState } from 'react'; 19 | import { useIntl } from 'react-intl'; 20 | 21 | import getTrad from '../../utils/getTrad'; 22 | 23 | function MediaCard(props) { 24 | const { id } = props; 25 | 26 | const [media, setMedia] = useState(null); 27 | const [loaded, setLoaded] = useState(false); 28 | const [error, setError] = useState(false); 29 | 30 | const { formatMessage } = useIntl(); 31 | 32 | const request = useFetchClient(); 33 | 34 | useEffect(() => { 35 | async function fetchData() { 36 | const requestUri = `/upload/files?page=1&pageSize=1&filters[$and][0][id]=${id}`; 37 | 38 | try { 39 | const result = await request.get(requestUri); 40 | 41 | const { data = {} } = result; 42 | 43 | const { results = [] } = data; 44 | 45 | if (results.length > 0) { 46 | setMedia(results[0]); 47 | setLoaded(true); 48 | } else { 49 | setError( 50 | formatMessage({ 51 | id: getTrad('plugin.admin.paperTrail.mediaNotFound'), 52 | defaultMessage: 'Empty' 53 | }) 54 | ); 55 | } 56 | } catch (Err) { 57 | console.warn('paper-trail: ', Err); 58 | setError(Err); 59 | } 60 | } 61 | 62 | fetchData(); 63 | }, [id, formatMessage, request]); 64 | 65 | return ( 66 | 67 | {error ? ( 68 | 78 | {String(error)} 79 | 80 | ) : null} 81 | {!error && loaded && media ? ( 82 | 87 | 88 | 95 | {!media?.mime?.includes('image') ? : null} 96 | 97 | 98 | 99 | 100 | {String(media?.name)} 101 | {String(media?.mime)} 102 | 103 | 104 | {formatMessage({ 105 | id: getTrad('plugin.admin.paperTrail.media'), 106 | defaultMessage: 'Media' 107 | })} 108 | 109 | 110 | 111 | ) : ( 112 | 122 | 123 | 124 | )} 125 | 126 | ); 127 | } 128 | 129 | MediaCard.propTypes = { 130 | id: PropTypes.number.isRequired 131 | }; 132 | 133 | export default MediaCard; 134 | -------------------------------------------------------------------------------- /admin/src/components/PaperTrailReview/PaperTrailReview.js: -------------------------------------------------------------------------------- 1 | import { 2 | Accordion, 3 | AccordionContent, 4 | AccordionToggle, 5 | BaseHeaderLayout, 6 | Box, 7 | Divider, 8 | JSONInput, 9 | Link 10 | } from '@strapi/design-system'; 11 | import { useCMEditViewDataManager } from '@strapi/helper-plugin'; 12 | import { ArrowLeft } from '@strapi/icons'; 13 | import PropTypes from 'prop-types'; 14 | import React, { Fragment, useEffect, useMemo, useState } from 'react'; 15 | import { useIntl } from 'react-intl'; 16 | 17 | import prepareTrailFromSchema from '../../../../server/utils/prepareTrailFromSchema'; 18 | import buildPayload from '../../utils/buildPayload'; 19 | import getTrad from '../../utils/getTrad'; 20 | import RenderField from '../RenderField/RenderField'; 21 | 22 | function PaperTrailReview(props) { 23 | const { trail, revisedFields, setShowReviewStep } = props; 24 | const { content } = trail; 25 | const [expanded, setExpanded] = useState(false); 26 | const [changePayload, setChangePayload] = useState({}); 27 | 28 | const { layout } = useCMEditViewDataManager(); 29 | 30 | const { trail: trimmedContent } = useMemo(() => { 31 | return prepareTrailFromSchema(content, layout); 32 | }, [content, layout]); 33 | 34 | const { formatMessage } = useIntl(); 35 | 36 | useEffect(() => { 37 | let changePayloadObj = buildPayload(trimmedContent, revisedFields); 38 | 39 | setChangePayload(changePayloadObj); 40 | }, [trimmedContent, revisedFields]); 41 | 42 | return ( 43 | 44 | 45 | } 50 | onClick={event => { 51 | event.preventDefault(); 52 | setShowReviewStep(false); 53 | }} 54 | > 55 | {formatMessage({ 56 | id: getTrad('plugin.admin.paperTrail.back'), 57 | defaultMessage: 'Back' 58 | })} 59 | 60 | } 61 | title={formatMessage({ 62 | id: getTrad('plugin.admin.paperTrail.reviewChanges'), 63 | defaultMessage: 'Review changes' 64 | })} 65 | subtitle={formatMessage({ 66 | id: getTrad('plugin.admin.paperTrail.reviewChangesDescription'), 67 | defaultMessage: 68 | "Review the below changes carefully. Upon clicking 'Restore' the record will be instantly updated with the selected values." 69 | })} 70 | as="h3" 71 | /> 72 | 73 | 74 | 75 | 76 | 77 | {Object.keys(changePayload).map(key => ( 78 | 84 | ))} 85 | 86 | 87 | setExpanded(s => !s)} 90 | id="acc-field-pt-raw" 91 | > 92 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | ); 108 | } 109 | 110 | PaperTrailReview.propTypes = { 111 | trail: PropTypes.shape({ 112 | change: PropTypes.string, 113 | content: PropTypes.object, 114 | contentType: PropTypes.string, 115 | createdAt: PropTypes.string, 116 | id: PropTypes.number, 117 | recordId: PropTypes.string, 118 | updatedAt: PropTypes.string, 119 | version: PropTypes.number 120 | }), 121 | revisedFields: PropTypes.arrayOf(PropTypes.string) 122 | }; 123 | 124 | export default PaperTrailReview; 125 | -------------------------------------------------------------------------------- /admin/src/components/TrailTable/TrailTable.js: -------------------------------------------------------------------------------- 1 | import { 2 | Box, 3 | Flex, 4 | IconButton, 5 | Table, 6 | Tbody, 7 | Td, 8 | Th, 9 | Thead, 10 | Tr, 11 | Typography, 12 | VisuallyHidden 13 | } from '@strapi/design-system'; 14 | import { Eye } from '@strapi/icons'; 15 | import { format, parseISO } from 'date-fns'; 16 | import PropTypes from 'prop-types'; 17 | import React, { Fragment } from 'react'; 18 | import { useIntl } from 'react-intl'; 19 | 20 | import getTrad from '../../utils/getTrad'; 21 | import getUser from '../../utils/getUser'; 22 | import TrailsTablePagination from '../TrailsTablePagination/TrailsTablePagination'; 23 | 24 | function TrailTable(props) { 25 | const { trails, setViewRevision, page, pageSize, total, pageCount, setPage } = 26 | props; 27 | 28 | const { formatMessage } = useIntl(); 29 | 30 | return ( 31 | 32 | {trails && trails.length > 0 && ( 33 | 34 | 35 | 42 | 43 | 44 | 45 | 46 | 54 | 62 | 70 | 78 | 86 | 87 | 88 | 89 | {trails.map(trail => ( 90 | 91 | 96 | 101 | 106 | 111 | 124 | 125 | ))} 126 | 127 |
47 | 48 | {formatMessage({ 49 | id: getTrad('plugin.admin.paperTrail.version'), 50 | defaultMessage: 'Version' 51 | })} 52 | 53 | 55 | 56 | {formatMessage({ 57 | id: getTrad('plugin.admin.paperTrail.changeType'), 58 | defaultMessage: 'Change Type' 59 | })} 60 | 61 | 63 | 64 | {formatMessage({ 65 | id: getTrad('plugin.admin.paperTrail.createdNaked'), 66 | defaultMessage: 'Created' 67 | })} 68 | 69 | 71 | 72 | {formatMessage({ 73 | id: getTrad('plugin.admin.paperTrail.createdByNaked'), 74 | defaultMessage: 'Created By' 75 | })} 76 | 77 | 79 | 80 | {formatMessage({ 81 | id: getTrad('plugin.admin.paperTrail.actions'), 82 | defaultMessage: 'Actions' 83 | })} 84 | 85 |
92 | 93 | {trail.version} 94 | 95 | 97 | 98 | {trail.change} 99 | 100 | 102 | 103 | {format(parseISO(trail.createdAt), 'MMM d, yyyy HH:mm')} 104 | 105 | 107 | 108 | {getUser(trail)} 109 | 110 | 112 | 113 | setViewRevision(trail)} 115 | label={`${formatMessage({ 116 | id: getTrad('plugin.admin.paperTrail.viewVersion'), 117 | defaultMessage: 'View version' 118 | })} ${trail.version}`} 119 | noBorder 120 | icon={} 121 | /> 122 | 123 |
128 | 129 | 136 | 137 |
138 | )} 139 | {!trails || 140 | (trails.length === 0 && ( 141 | 142 | {formatMessage({ 143 | id: getTrad('plugin.admin.paperTrail.noTrails'), 144 | defaultMessage: 'Close' 145 | })} 146 | 147 | ))} 148 |
149 | ); 150 | } 151 | 152 | TrailTable.propTypes = { 153 | setViewRevision: PropTypes.func.isRequired, 154 | page: PropTypes.number.isRequired, 155 | total: PropTypes.number.isRequired, 156 | pageSize: PropTypes.number.isRequired, 157 | pageCount: PropTypes.number.isRequired, 158 | setPage: PropTypes.func.isRequired, 159 | trails: PropTypes.arrayOf( 160 | PropTypes.shape({ 161 | change: PropTypes.string, 162 | content: PropTypes.object, 163 | contentType: PropTypes.string, 164 | createdAt: PropTypes.string, 165 | id: PropTypes.number, 166 | recordId: PropTypes.string, 167 | updatedAt: PropTypes.string, 168 | version: PropTypes.number 169 | }) 170 | ) 171 | }; 172 | 173 | export default TrailTable; 174 | -------------------------------------------------------------------------------- /admin/src/components/RenderField/RenderField.js: -------------------------------------------------------------------------------- 1 | import { 2 | Accordion, 3 | AccordionContent, 4 | AccordionToggle, 5 | BaseCheckbox, 6 | Box, 7 | DateTimePicker, 8 | JSONInput, 9 | NumberInput, 10 | TextInput, 11 | Textarea, 12 | ToggleCheckbox, 13 | Typography 14 | } from '@strapi/design-system'; 15 | import { useCMEditViewDataManager } from '@strapi/helper-plugin'; 16 | import PropTypes from 'prop-types'; 17 | import React, { Fragment, useState } from 'react'; 18 | import { useIntl } from 'react-intl'; 19 | 20 | import getTrad from '../../utils/getTrad'; 21 | import returnFieldType from '../../utils/returnFieldType'; 22 | import MediaField from '../MediaField/MediaField'; 23 | import RelationField from '../RelationField/RelationField'; 24 | 25 | function RenderField(props) { 26 | const { name, value, setRevisedFields, hideAccordion } = props; 27 | 28 | const { formatMessage } = useIntl(); 29 | 30 | const { layout } = useCMEditViewDataManager(); 31 | 32 | const { attributes } = layout; 33 | 34 | /** 35 | * get the schema attributes and handle unknown types as strings 36 | */ 37 | 38 | const fieldAttr = attributes[name]; 39 | 40 | const { type } = fieldAttr; 41 | 42 | const validType = returnFieldType(type); 43 | 44 | const [expanded, setExpanded] = useState(true); 45 | const [selected, setSelected] = useState(false); 46 | 47 | const renderFields = () => { 48 | return ( 49 | 50 | 51 | {hideAccordion && ( 52 | 53 | {name}: 54 | 55 | )} 56 | {validType === 'datetime' && ( 57 | 'Date picker, current is undefined'} 66 | value={value} 67 | /> 68 | )} 69 | {['string', 'enumeration', 'email', 'biginteger', 'uid'].includes( 70 | validType 71 | ) && ( 72 | 81 | )} 82 | {['integer', 'decimal', 'float'].includes(validType) && ( 83 | 93 | )} 94 | {['text', 'richtext'].includes(validType) && ( 95 | 105 | )} 106 | {validType === 'boolean' && ( 107 | 119 | )} 120 | {/* TODO: investigated a better way of managing this, and flag it in the readme as a risk */} 121 | {['json', 'dynamiczone', 'component'].includes(validType) && ( 122 | 123 | )} 124 | {validType === 'media' && ( 125 | 126 | )} 127 | {validType === 'relation' && ( 128 | 129 | )} 130 | 131 | 132 | ); 133 | }; 134 | 135 | return ( 136 | 137 | {!hideAccordion && ( 138 | setExpanded(s => !s)} 141 | id={`acc-field-pt-${name}`} 142 | > 143 | { 162 | setSelected(value); 163 | setRevisedFields(name, value); 164 | }} 165 | value={selected} 166 | /> 167 | } 168 | /> 169 | {renderFields()} 170 | 171 | )} 172 | {hideAccordion && renderFields()} 173 | 174 | ); 175 | } 176 | 177 | RenderField.propTypes = { 178 | hideAccordion: PropTypes.bool, 179 | setRevisedFields: PropTypes.func, 180 | name: PropTypes.string, 181 | value: PropTypes.any 182 | }; 183 | 184 | export default RenderField; 185 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Strapi Plugin Paper Trail 2 | 3 | Accountability and content versioning for strapi v4+. 4 | 5 | [![npm version](https://badge.fury.io/js/strapi-plugin-paper-trail.svg)](https://badge.fury.io/js/strapi-plugin-paper-trail) [![Unit Tests](https://github.com/PenguinOfWar/strapi-plugin-paper-trail/actions/workflows/unit-test.yml/badge.svg)](https://github.com/PenguinOfWar/strapi-plugin-paper-trail/actions/workflows/unit-test.yml) 6 | 7 | ## Requirements 8 | 9 | 1. `node` `v14` or higher 10 | 2. `strapi` `v4.10` or higher 11 | 12 | ## Features 13 | 14 | - Automatic revision history and auditing with support for all major strapi content types including relations, media, components, and dynamic zones via the admin panel. 15 | - Support for collection and single content types. 16 | - Roll-back capabilities with the option to select specific fields to restore via the admin panel. 17 | - Tracks revision history by both admins and users. 18 | - Internationalization (i18n) plugin support. 19 | 20 | ## Installation 21 | 22 | To install this plugin, you need to add an NPM dependency to your Strapi application: 23 | 24 | ```sh 25 | # Using Yarn 26 | yarn add strapi-plugin-paper-trail 27 | 28 | # Or using NPM 29 | npm install strapi-plugin-paper-trail --save 30 | ``` 31 | 32 | Enable the plugin by adding the following in `./config/plugins.js`. 33 | 34 | ```js 35 | module.exports = { 36 | // ... 37 | 'paper-trail': { 38 | enabled: true 39 | } 40 | // ... 41 | }; 42 | ``` 43 | 44 | Or, if you are using TypeScript, in `./config/plugins.ts`. 45 | 46 | ```ts 47 | export default { 48 | // ... 49 | 'paper-trail': { 50 | enabled: true 51 | } 52 | // ... 53 | }; 54 | ``` 55 | 56 | **Heads up!** Disabling the plugin will destroy the Paper Trail model and everything in it, clearing your revision history. 57 | 58 | Then, you'll need to build your admin panel: 59 | 60 | ```sh 61 | # Using Yarn 62 | yarn build 63 | 64 | # Or using NPM 65 | npm run build 66 | ``` 67 | 68 | ## Usage 69 | 70 | The functionality of this plugin is opt-in on a per content type basis and can be disabled at any time. 71 | 72 | To enable the plugin, edit the content type via the Content-Type Builder screen. 73 | 74 | ![Screenshot 2023-06-05 at 16 27 12](https://github.com/PenguinOfWar/strapi-plugin-paper-trail/assets/1913241/98d2d386-55e7-4bcc-be76-238deb64f4dd) 75 | 76 | Or by modifying the `pluginOption` object on your models `schema.json`. 77 | 78 | ```json 79 | // ... 80 | "pluginOptions": { 81 | "paperTrail": { 82 | "enabled": true 83 | } 84 | }, 85 | // ... 86 | ``` 87 | 88 | Once enabled on the model, Paper Trail will be listening for changes to your records and will automatically create revisions when records are created or updated. These changes can be viewed directly from the content manager edit view. 89 | 90 | For convenience, the plugin will differentiate `CREATE` from `UPDATE` and will display which user made the change (regardless of whether they are an admin or a user permissions plugin user). 91 | 92 | ![Screenshot 2023-06-05 at 16 31 38](https://github.com/PenguinOfWar/strapi-plugin-paper-trail/assets/1913241/60bd144e-eb79-4920-8cc0-de1a6eb26185) 93 | 94 | Clicking 'View all' will show the entire revision history for this record. 95 | 96 | ![Screenshot 2023-06-05 at 16 31 42](https://github.com/PenguinOfWar/strapi-plugin-paper-trail/assets/1913241/cf04c054-3237-4cdf-889c-c8086623362d) 97 | 98 | The revision history for each field that was touched during the `CREATE` or `UPDATE` will be displayed, and you are able to select which fields you would like to restore by checking the checkbox on each accordion. 99 | 100 | ![Screenshot 2023-06-05 at 16 31 59](https://github.com/PenguinOfWar/strapi-plugin-paper-trail/assets/1913241/a5a2431e-8ee6-4240-9bc6-7e853f93d6d8) 101 | 102 | Once you are ready, you will get a final chance to review the entire scope of the fields that will be restored. You can also view the JSON object for debugging purposes (or simply as a way to quickly grok the entire change). 103 | 104 | ![Screenshot 2023-06-05 at 16 32 08](https://github.com/PenguinOfWar/strapi-plugin-paper-trail/assets/1913241/102007a7-f650-41d9-b0a5-ab896bc10f16) 105 | 106 | Clicking 'Restore' will then immediately overwrite the selected fields on the original record, restoring your revision. 107 | 108 | ## Notes & Considerations 109 | 110 | While I have tried to keep the plugin as simple and intuitive to use as possible, there are some notes and considerations to mention. Some of these are `strapi` specific, some are specific to the challenge of version control, and others are plugin specific challenges. 111 | 112 | 1. The plugin has currently only been tested on `node v18` and `node v20`, though it should work perfectly on any node version that `strapi v4` directly supports (currently `node v14` and up). 113 | 2. The plugin relies on the content type plugin `UID` property to identify the correct content type and associate the revision history. If you change this value you will lose previous revision histories (all revision history records can be manually browsed and modified from `Content Manager > Collection Types > Trail`). 114 | 3. This has not been tested with all available custom field plugins, however as long as the custom field plugin implements on top of the core strapi content manager types (e.g. `string`, `text`, `biginteger`, `json`, `component`, and so on) and isn't doing anything too arcane, then it should be fine. 115 | 4. The plugin is a middleware listening on the admin and user content management endpoints. Making changes directly to the records outside of this scope (e.g. from a custom service or controller) will not be logged as a revision by the plugin, however it shouldn't be difficult to manually implement this if needed. 116 | 5. Attempting to restore a unique field with a duplicate value will cause the request to fail. 117 | 6. Likewise, attempting to restore a field that has been since deleted from the schema or renamed will cause the attempt to fail (restoration of a delete field is on the roadmap) 118 | 7. `password` type is not supported for security reasons. 119 | 8. The plugin is still in early development, use with caution! 120 | 9. Pull requests for new features and fixes welcome and encouraged 🚀 121 | 122 | ## Roadmap 123 | 124 | In no particular order and subject to change depending on priorities. 125 | 126 | 1. Compare diffs against current vs chosen revision, or two separate revisions. 127 | 2. Restoration for records deleted by `DELETE` event. 128 | 3. Small enhancements to better leverage available `strapi` server hooks instead of custom code. 129 | 4. Better support for only logging changed fields (currently the `strapi` admin sends the entire record back and not just the changes fields) to reduce revision noise. 130 | 5. Plugin management panel for purging revision history. 131 | 6. Selecting which field to send the revised change to on the record (supporting schema name changes). 132 | 133 | ## Support 134 | 135 | Please create an issue in the [issue tracker](https://github.com/PenguinOfWar/strapi-plugin-paper-trail/issues) if you have a problem or need support. Please select the correct label when creating your issue (e.g. `help wanted` or `bug`). 136 | 137 | ## Contributing 138 | 139 | Contributions are welcome. Note that code style is enforced with `prettier`. Kindly adhere to this while making contributions. 140 | 141 | ### Step 1: Fork this repo 142 | 143 | ### Step 2: Start hacking 144 | 145 | ### Step 3: [Open a PR](https://github.com/PenguinOfWar/strapi-plugin-paper-trail/pulls) 146 | 147 | ### Step 4: Profit 💰💰💰 148 | 149 | ## License 150 | 151 | MIT License 152 | -------------------------------------------------------------------------------- /admin/src/components/PaperTrail/PaperTrail.js: -------------------------------------------------------------------------------- 1 | import { 2 | Box, 3 | Button, 4 | Divider, 5 | Loader, 6 | Typography 7 | } from '@strapi/design-system'; 8 | import { 9 | useCMEditViewDataManager, 10 | useFetchClient 11 | } from '@strapi/helper-plugin'; 12 | import { format, parseISO } from 'date-fns'; 13 | import React, { Fragment, useCallback, useEffect, useState } from 'react'; 14 | import { useIntl } from 'react-intl'; 15 | import { useParams } from 'react-router-dom'; 16 | 17 | import getTrad from '../../utils/getTrad'; 18 | import getUser from '../../utils/getUser'; 19 | import PaperTrailViewer from '../PaperTrailViewer/PaperTrailViewer'; 20 | 21 | function PaperTrail() { 22 | /** 23 | * Get the current schema 24 | */ 25 | const { layout } = useCMEditViewDataManager(); 26 | 27 | const { uid, pluginOptions = {} } = layout; 28 | 29 | const { formatMessage } = useIntl(); 30 | // params works for collection types but not single types 31 | const { id, collectionType } = useParams(); 32 | 33 | const [recordId, setRecordId] = useState(id); 34 | 35 | const paperTrailEnabled = pluginOptions?.paperTrail?.enabled; 36 | 37 | // TODO: add this to config/plugins.ts, needs a custom endpoint 38 | // https://forum.strapi.io/t/custom-field-settings/23068 39 | const pageSize = 15; 40 | 41 | const request = useFetchClient(); 42 | 43 | const [trails, setTrails] = useState([]); 44 | const [loaded, setLoaded] = useState(false); 45 | const [initialLoad, setInitialLoad] = useState(false); 46 | const [current, setCurrent] = useState(null); 47 | const [error, setError] = useState(false); 48 | const [modalVisible, setModalVisible] = useState(false); 49 | const [page, setPage] = useState(1); 50 | const [total, setTotal] = useState(0); 51 | const [pageCount, setPageCount] = useState(1); 52 | 53 | // if collectionType is single then fetch the ID (if exists) from the server and set `1` if nothing. 54 | 55 | const getSingleTypeId = useCallback(async () => { 56 | const requestUri = `/content-manager/single-types/${uid}/`; 57 | 58 | try { 59 | const result = await request.get(requestUri); 60 | 61 | const { data = {} } = result; 62 | 63 | const { id } = data; 64 | 65 | setRecordId(id); 66 | 67 | return id; 68 | } catch (err) { 69 | console.warn('paper-trail:', 'No existing single type for this UID', err); 70 | } 71 | 72 | return null; 73 | }, [uid, request]); 74 | 75 | useEffect(() => { 76 | if (collectionType === 'single-types') { 77 | getSingleTypeId(); 78 | } 79 | }, [collectionType, getSingleTypeId]); 80 | 81 | useEffect(() => { 82 | async function getTrails(page, pageSize) { 83 | const params = new URLSearchParams({ 84 | page, 85 | pageSize, 86 | sort: 'version:DESC', 87 | 'filters[$and][0][contentType][$eq]': uid, 88 | 'filters[$and][1][recordId][$eq]': recordId 89 | }).toString(); 90 | 91 | const requestUri = `/content-manager/collection-types/plugin::paper-trail.trail?${params}`; 92 | 93 | try { 94 | const result = await request.get(requestUri); 95 | 96 | const { data = {} } = result; 97 | 98 | const { results = [], pagination } = data; 99 | 100 | const { total, pageCount } = pagination; 101 | 102 | setTotal(total); 103 | setPageCount(pageCount); 104 | setTrails(results); 105 | 106 | if (page === 1 && total > 0) { 107 | setCurrent(results[0]); 108 | } 109 | 110 | setLoaded(true); 111 | setInitialLoad(true); 112 | } catch (Err) { 113 | console.warn('paper-trail: ', Err); 114 | setError(Err); 115 | } 116 | } 117 | 118 | if (!loaded && paperTrailEnabled && recordId) { 119 | getTrails(page, pageSize); 120 | } else { 121 | setInitialLoad(true); 122 | } 123 | }, [loaded, uid, recordId, page, paperTrailEnabled, request]); 124 | 125 | /** 126 | * event listener for submit button 127 | */ 128 | 129 | const handler = useCallback(async () => { 130 | setTimeout(async () => { 131 | if (collectionType === 'single-types') { 132 | await getSingleTypeId(); 133 | } 134 | setPage(1); 135 | setLoaded(false); 136 | setInitialLoad(false); 137 | }, 1000); 138 | }, [getSingleTypeId, collectionType]); 139 | 140 | const handleSetPage = useCallback(newPage => { 141 | setPage(newPage); 142 | setLoaded(false); 143 | }, []); 144 | 145 | /** 146 | * TODO: this event listener is not working properly 100% of the time needs a better solution 147 | */ 148 | 149 | useEffect(() => { 150 | const buttons = document.querySelectorAll('main button[type=submit]'); 151 | if (buttons[0]) { 152 | const button = buttons[0]; 153 | 154 | button.addEventListener('click', handler); 155 | 156 | return () => { 157 | button.removeEventListener('click', handler); 158 | }; 159 | } 160 | }, [handler]); 161 | 162 | if (!paperTrailEnabled) { 163 | return ; 164 | } 165 | 166 | // TODO: Add diff comparison 167 | // TODO: Add up/down for changing UIDs and enabling/disabling plugin 168 | 169 | return ( 170 | 171 | 183 | 188 | {formatMessage({ 189 | id: getTrad('plugin.admin.paperTrail.title'), 190 | defaultMessage: 'Paper Trail' 191 | })} 192 | 193 | 194 | 195 | 196 | {initialLoad ? ( 197 | 198 | {total === 0 && ( 199 | 200 | {formatMessage({ 201 | id: getTrad('plugin.admin.paperTrail.noTrails'), 202 | defaultMessage: 'No versions (yet)' 203 | })} 204 | 205 | )} 206 | {total > 0 && current && ( 207 | 208 |

209 | 210 | {formatMessage({ 211 | id: getTrad('plugin.admin.paperTrail.currentVersion'), 212 | defaultMessage: 'Current version:' 213 | })}{' '} 214 | {total} 215 | 216 |

217 |

218 | 219 | {formatMessage({ 220 | id: getTrad('plugin.admin.paperTrail.created'), 221 | defaultMessage: 'Created:' 222 | })}{' '} 223 | 224 | 225 | {format(parseISO(current.createdAt), 'MMM d, yyyy HH:mm')} 226 | 227 |

228 |

229 | 230 | {formatMessage({ 231 | id: getTrad('plugin.admin.paperTrail.createdBy'), 232 | defaultMessage: 'Created by:' 233 | })}{' '} 234 | 235 | 236 | {getUser(current)} 237 | 238 |

239 | 240 | 246 | 247 |
248 | )} 249 |
250 | ) : ( 251 | 252 | )} 253 |
254 | 267 |
268 | ); 269 | } 270 | 271 | export default PaperTrail; 272 | -------------------------------------------------------------------------------- /admin/src/components/PaperTrailViewer/PaperTrailViewer.js: -------------------------------------------------------------------------------- 1 | import { 2 | Button, 3 | Dialog, 4 | DialogBody, 5 | DialogFooter, 6 | Flex, 7 | ModalBody, 8 | ModalFooter, 9 | ModalHeader, 10 | ModalLayout, 11 | Typography 12 | } from '@strapi/design-system'; 13 | import { 14 | useCMEditViewDataManager, 15 | useFetchClient 16 | } from '@strapi/helper-plugin'; 17 | import { ExclamationMarkCircle } from '@strapi/icons'; 18 | import PropTypes from 'prop-types'; 19 | import React, { Fragment, useCallback, useState } from 'react'; 20 | import { useIntl } from 'react-intl'; 21 | 22 | import prepareTrailFromSchema from '../../../../server/utils/prepareTrailFromSchema'; 23 | import buildPayload from '../../utils/buildPayload'; 24 | import getTrad from '../../utils/getTrad'; 25 | import PaperTrailRestoreView from '../PaperTrailRestoreView/PaperTrailRestoreView'; 26 | import PaperTrailReview from '../PaperTrailReview/PaperTrailReview'; 27 | import TrailTable from '../TrailTable/TrailTable'; 28 | 29 | function PaperTrailViewer(props) { 30 | const { 31 | visible, 32 | setVisible, 33 | trails, 34 | setError, 35 | error, 36 | page, 37 | pageSize, 38 | total, 39 | pageCount, 40 | setPage, 41 | collectionType 42 | } = props; 43 | const [viewRevision, setViewRevision] = useState(null); 44 | const [revisedFields, setRevisedFields] = useState([]); 45 | const [showReviewStep, setShowReviewStep] = useState(false); 46 | 47 | const { formatMessage } = useIntl(); 48 | 49 | const handleClose = useCallback(() => { 50 | setVisible(!visible); 51 | setViewRevision(null); 52 | setRevisedFields([]); 53 | setShowReviewStep(false); 54 | }, [visible, setVisible]); 55 | 56 | const handleSetViewRevision = useCallback(viewRevisionState => { 57 | setRevisedFields([]); 58 | setViewRevision(viewRevisionState); 59 | setShowReviewStep(false); 60 | }, []); 61 | 62 | const handleSetRevisedFields = useCallback( 63 | (name, checked) => { 64 | /** 65 | * if checked, add the name to the array otherwise splice 66 | */ 67 | 68 | if (checked && !revisedFields.includes(name)) { 69 | setRevisedFields([...revisedFields, name]); 70 | } 71 | 72 | if (!checked && revisedFields.includes(name)) { 73 | const index = revisedFields.indexOf(name); 74 | let newArr = [...revisedFields]; 75 | newArr.splice(index, 1); 76 | 77 | setRevisedFields(newArr); 78 | } 79 | }, 80 | [revisedFields] 81 | ); 82 | 83 | const handleSetShowReviewStep = useCallback(bool => { 84 | setShowReviewStep(bool); 85 | if (!bool) { 86 | setRevisedFields([]); 87 | } 88 | }, []); 89 | 90 | /** 91 | * Submission handler for restoring 92 | */ 93 | 94 | const request = useFetchClient(); 95 | const { layout } = useCMEditViewDataManager(); 96 | 97 | const handleRestoreSubmission = useCallback(async () => { 98 | /** 99 | * Gather the final payload 100 | */ 101 | 102 | // TODO: Warning about changing content type/UID dropping trails from the admin panel / killing relationship 103 | 104 | const { recordId, content, contentType } = viewRevision; 105 | 106 | const { trail: trimmedContent } = prepareTrailFromSchema(content, layout); 107 | 108 | const payload = buildPayload(trimmedContent, revisedFields); 109 | 110 | try { 111 | const requestUri = 112 | collectionType === 'single-types' 113 | ? `/content-manager/${collectionType}/${contentType}` 114 | : `/content-manager/${collectionType}/${contentType}/${recordId}`; 115 | 116 | await request.put(requestUri, payload); 117 | 118 | window.location.reload(); 119 | } catch (Err) { 120 | setError(Err); 121 | console.warn('paper-trail:', Err); 122 | } 123 | }, [layout, viewRevision, revisedFields, request, setError, collectionType]); 124 | 125 | return ( 126 | 127 | {visible && ( 128 | handleClose()} labelledBy="title"> 129 | 130 | 136 | {formatMessage({ 137 | id: getTrad('plugin.admin.paperTrail.revisionHistory'), 138 | defaultMessage: 'Revision History' 139 | })} 140 | 141 | 142 | 143 | {!viewRevision && ( 144 | 153 | )} 154 | {viewRevision && !showReviewStep && ( 155 | 160 | )} 161 | {viewRevision && showReviewStep && ( 162 | 167 | )} 168 | {/* error alert */} 169 | {error && ( 170 | setError(null)} 172 | title={formatMessage({ 173 | id: getTrad('plugin.admin.paperTrail.error'), 174 | defaultMessage: 'Error' 175 | })} 176 | isOpen={Boolean(error)} 177 | > 178 | }> 179 | 180 | 181 | {String(error)} 182 | 183 | 184 | 185 | setError(null)} variant="tertiary"> 188 | {formatMessage({ 189 | id: getTrad('plugin.admin.paperTrail.close'), 190 | defaultMessage: 'Close' 191 | })} 192 | 193 | } 194 | /> 195 | 196 | )} 197 | 198 | 201 | {!showReviewStep && 202 | revisedFields && 203 | revisedFields.length > 0 && ( 204 | 213 | )} 214 | {showReviewStep && 215 | revisedFields && 216 | revisedFields.length > 0 && ( 217 | 226 | )} 227 | 233 | 234 | } 235 | /> 236 | 237 | )} 238 |
239 | ); 240 | } 241 | 242 | PaperTrailViewer.propTypes = { 243 | visible: PropTypes.bool, 244 | setVisible: PropTypes.func.isRequired, 245 | error: PropTypes.any, 246 | setError: PropTypes.func.isRequired, 247 | page: PropTypes.number.isRequired, 248 | total: PropTypes.number.isRequired, 249 | pageSize: PropTypes.number.isRequired, 250 | pageCount: PropTypes.number.isRequired, 251 | setPage: PropTypes.func.isRequired, 252 | trails: PropTypes.arrayOf( 253 | PropTypes.shape({ 254 | change: PropTypes.string, 255 | content: PropTypes.object, 256 | contentType: PropTypes.string, 257 | createdAt: PropTypes.string, 258 | id: PropTypes.number, 259 | recordId: PropTypes.string, 260 | updatedAt: PropTypes.string, 261 | version: PropTypes.number 262 | }) 263 | ) 264 | }; 265 | 266 | export default PaperTrailViewer; 267 | --------------------------------------------------------------------------------