├── .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 |
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 | |
47 |
48 | {formatMessage({
49 | id: getTrad('plugin.admin.paperTrail.version'),
50 | defaultMessage: 'Version'
51 | })}
52 |
53 | |
54 |
55 |
56 | {formatMessage({
57 | id: getTrad('plugin.admin.paperTrail.changeType'),
58 | defaultMessage: 'Change Type'
59 | })}
60 |
61 | |
62 |
63 |
64 | {formatMessage({
65 | id: getTrad('plugin.admin.paperTrail.createdNaked'),
66 | defaultMessage: 'Created'
67 | })}
68 |
69 | |
70 |
71 |
72 | {formatMessage({
73 | id: getTrad('plugin.admin.paperTrail.createdByNaked'),
74 | defaultMessage: 'Created By'
75 | })}
76 |
77 | |
78 |
79 |
80 | {formatMessage({
81 | id: getTrad('plugin.admin.paperTrail.actions'),
82 | defaultMessage: 'Actions'
83 | })}
84 |
85 | |
86 |
87 |
88 |
89 | {trails.map(trail => (
90 |
91 | |
92 |
93 | {trail.version}
94 |
95 | |
96 |
97 |
98 | {trail.change}
99 |
100 | |
101 |
102 |
103 | {format(parseISO(trail.createdAt), 'MMM d, yyyy HH:mm')}
104 |
105 | |
106 |
107 |
108 | {getUser(trail)}
109 |
110 | |
111 |
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 | |
124 |
125 | ))}
126 |
127 |
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 | [](https://badge.fury.io/js/strapi-plugin-paper-trail) [](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 | 
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 | 
93 |
94 | Clicking 'View all' will show the entire revision history for this record.
95 |
96 | 
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 | 
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 | 
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 |
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 |
--------------------------------------------------------------------------------