This commit is contained in:
Salam-vsem
2025-05-24 19:02:39 +03:00
commit 2b72f5407b
52 changed files with 1457 additions and 0 deletions

51
.gitignore vendored Normal file
View File

@@ -0,0 +1,51 @@
# Dependencies
node_modules/
package-lock.json
yarn.lock
# Build
dist/
build/
.next/
out/
# Environment variables
.env
.env.local
.env.*.local
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# IDE
.idea/
.vscode/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Testing
coverage/
# Temporary files
*.tmp
*.temp
.cache/
# Debug
.debug/
# Misc
.DS_Store
*.pem
.env.local
.env.development.local
.env.test.local
.env.production.local

6
.npmrc Normal file
View File

@@ -0,0 +1,6 @@
# For scoped packages (@your-scope)
@mibu:registry=https://gitea.mibu-studio.com/api/packages/Mibu/npm/
//gitea.mibu-studio.com/api/packages/Mibu/npm/:_authToken="b12aa632d67f4acff239d0863a91815695627f8c"
# For non-scoped packages (optional, if you want to publish without scope)
# registry=https://gitea.example.com/api/packages/your-username/npm/

22
README.md Normal file
View File

@@ -0,0 +1,22 @@
# content-versioning
saves versions of configured collections
# Configuration
## contentVersionLimit
sets limit of saved versions per one collection. Default value = 50
```
// Strapi app configuration
// config/plugin.ts
export default () => ({
'content-versioning': {
config: {
contentVersionLimit: 3 // your own limit
}
}
});
```

2
admin/custom.d.ts vendored Normal file
View File

@@ -0,0 +1,2 @@
declare module '@strapi/design-system/*';
declare module '@strapi/design-system';

View File

@@ -0,0 +1,127 @@
import * as React from 'react';
import { Button, Checkbox, Dialog, Field, Flex, Typography } from '@strapi/design-system';
import { WarningCircle } from '@strapi/icons';
import { MessageDescriptor, useIntl } from 'react-intl';
import { styled } from 'styled-components';
import { getTranslation } from '../utils/getTranslation';
const TextAlignTypography = styled(Typography)`
text-align: center;
`;
interface IntlMessage extends MessageDescriptor {
values: object;
}
interface CheckboxConfirmationProps {
description: IntlMessage;
intlLabel: IntlMessage;
isCreating?: boolean;
name: string;
onChange: (event: { target: { name: string; value: boolean; type: string } }) => void;
value: boolean;
}
const CheckboxConfirmation = ({
description,
isCreating = false,
intlLabel,
name,
onChange,
value,
}: CheckboxConfirmationProps) => {
const { formatMessage } = useIntl();
const [isOpen, setIsOpen] = React.useState(false);
const handleChange = (value: boolean) => {
if (isCreating || value) {
return onChange({ target: { name, value, type: 'checkbox' } });
}
if (!value) {
return setIsOpen(true);
}
return null;
};
const handleConfirm = () => {
onChange({ target: { name, value: false, type: 'checkbox' } });
};
const label = intlLabel.id
? formatMessage(
{ id: intlLabel.id, defaultMessage: intlLabel.defaultMessage },
{ ...intlLabel.values }
)
: name;
const hint = description
? formatMessage(
{ id: description.id, defaultMessage: description.defaultMessage },
{ ...description.values }
)
: '';
return (
<Dialog.Root open={isOpen} onOpenChange={setIsOpen}>
<Field.Root hint={hint} name={name}>
<Checkbox onCheckedChange={handleChange} checked={value}>
{label}
</Checkbox>
<Field.Hint />
</Field.Root>
<Dialog.Content>
<Dialog.Header>
{formatMessage({
id: getTranslation('CheckboxConfirmation.Modal.title'),
defaultMessage: 'Disable versioning',
})}
</Dialog.Header>
<Dialog.Body icon={<WarningCircle />}>
<Flex direction="column" alignItems="stretch" gap={2}>
<Flex justifyContent="center">
<TextAlignTypography>
{formatMessage({
id: getTranslation('CheckboxConfirmation.Modal.content'),
defaultMessage:
'Disabling versioning will engender the deletion of all your content but the one associated to your default locale (if existing).',
})}
</TextAlignTypography>
</Flex>
<Flex justifyContent="center">
<Typography fontWeight="semiBold">
{formatMessage({
id: getTranslation('CheckboxConfirmation.Modal.body'),
defaultMessage: 'Do you want to disable it?',
})}
</Typography>
</Flex>
</Flex>
</Dialog.Body>
<Dialog.Footer>
<Dialog.Cancel>
<Button variant="tertiary">
{formatMessage({
id: 'components.popUpWarning.button.cancel',
defaultMessage: 'No, cancel',
})}
</Button>
</Dialog.Cancel>
<Dialog.Action>
<Button variant="danger-light" onClick={handleConfirm}>
{formatMessage({
id: getTranslation('CheckboxConfirmation.Modal.button-confirm'),
defaultMessage: 'Yes, disable',
})}
</Button>
</Dialog.Action>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
);
};
export { CheckboxConfirmation };

View File

@@ -0,0 +1,19 @@
import { useEffect, useRef } from 'react';
import { PLUGIN_ID } from '../pluginId';
type InitializerProps = {
setPlugin: (id: string) => void;
};
const Initializer = ({ setPlugin }: InitializerProps) => {
const ref = useRef(setPlugin);
useEffect(() => {
ref.current(PLUGIN_ID);
}, []);
return null;
};
export { Initializer };

View File

@@ -0,0 +1,5 @@
import { PuzzlePiece } from '@strapi/icons';
const PluginIcon = () => <PuzzlePiece />;
export { PluginIcon };

View File

@@ -0,0 +1,45 @@
import { Card, CardBody, CardTitle, Typography, Button } from '@strapi/design-system';
import React from 'react';
import { ContentVersion } from '../types';
import styled from 'styled-components';
type Props = {
isSelected: boolean;
version: ContentVersion;
onSelect: (version: ContentVersion) => void;
loading?: boolean;
};
const CardHeader = styled.div`
width: 100%;
display: flex;
flex-direction: column;
`;
export const VersionCard: React.FC<Props> = ({ version, onSelect, isSelected, loading }) => {
return (
<Card
key={version.documentId}
shadow="filterShadow"
width="100%"
background={isSelected ? 'success100' : 'neutral0'}
>
<CardBody width="100%" direction="column" gap={4}>
<CardHeader>
<CardTitle>
<Typography variant="delta">{new Date(version.createdAt).toLocaleString()}</Typography>
</CardTitle>
</CardHeader>
<Button
loading={loading}
disabled={isSelected}
width="100%"
variant="secondary"
onClick={() => onSelect(version)}
>
Select
</Button>
</CardBody>
</Card>
);
};

View File

@@ -0,0 +1,78 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { Typography } from '@strapi/design-system';
import { ContentVersion, EditViewContext } from '../types';
import { useVersions } from '../hooks/useVersions';
import { Loader } from '@strapi/icons';
import { unstable_useContentManagerContext as useContentManagerContext } from '@strapi/strapi/admin';
import { VersionCard } from './VersionCard';
import { Flex } from '@strapi/design-system';
import { PUBLISHED_AT_ATTRIBUTE_NAME } from '../consts';
import { usePrepareVersionFormData } from '../hooks/usePrepareVersionFormData';
export const VersionList: React.FC<EditViewContext> = ({
model,
meta,
document,
}: EditViewContext) => {
const [selectedVersion, setSelectedVersion] = useState<string | null>(null);
const { id: contentId, locale = null } = document || {};
const ctx = useContentManagerContext();
const { isLoading: isFetchingPrepareVersionData, prepareVersionFormData } =
usePrepareVersionFormData();
if (!contentId) return null;
const { versions, isLoading, loadVersions } = useVersions({ model });
const currentVersion = useMemo(() => versions[0], [versions]);
const handleSelectVersion = async (version: ContentVersion) => {
if (version.id === currentVersion.id) {
ctx.form.resetForm();
} else {
const preparedData = await prepareVersionFormData(version.id, locale);
ctx.form.setValues(preparedData);
}
setSelectedVersion(version.documentId);
};
const handleLoadVersions = useCallback(() => {
loadVersions(contentId, (versions) => {
const currentVersion = versions[0].documentId;
setSelectedVersion(currentVersion);
});
}, [contentId]);
const isDocumentPublished =
(document?.[PUBLISHED_AT_ATTRIBUTE_NAME] ||
meta?.availableStatus.some(
(doc: Record<string, unknown>) => doc[PUBLISHED_AT_ATTRIBUTE_NAME] !== null
)) &&
document?.status !== 'modified';
const shouldRefetchVersions = isDocumentPublished && !ctx.form.isSubmitting;
useEffect(() => {
if (shouldRefetchVersions) {
handleLoadVersions();
}
}, [shouldRefetchVersions, handleLoadVersions]);
return isLoading ? (
<Loader />
) : versions.length > 0 ? (
<Flex width="100%" maxHeight="400px" gap={2} overflow="scroll" direction="column">
{versions.map((version) => (
<VersionCard
key={version.documentId}
version={version}
onSelect={handleSelectVersion}
isSelected={version.documentId === selectedVersion}
loading={isFetchingPrepareVersionData}
/>
))}
</Flex>
) : (
<Typography textColor="neutral600">No versions available.</Typography>
);
};

View File

@@ -0,0 +1,16 @@
import { VersionList } from './VersionList';
import { EditViewContext } from '../types';
import { unstable_useContentManagerContext as useContentManagerContext } from '@strapi/strapi/admin';
import { get } from 'radash';
export const VersionPanel = (props: EditViewContext) => {
const { layout } = useContentManagerContext();
const isVersioningEnabled = get(layout, 'list.options.versions.versioned', false);
if (!isVersioningEnabled) return null;
return {
title: 'Версии',
content: <VersionList {...props} />,
};
};

1
admin/src/consts.ts Normal file
View File

@@ -0,0 +1 @@
export const PUBLISHED_AT_ATTRIBUTE_NAME = 'publishedAt';

View File

@@ -0,0 +1,20 @@
import { useFetchClient } from '@strapi/strapi/admin';
import { PLUGIN_ID } from '../pluginId';
import { useState } from 'react';
export const usePrepareVersionFormData = () => {
const [isLoading, setIsLoading] = useState(false);
const { get } = useFetchClient();
const prepareVersionFormData = async (versionId: number, locale: string | null) => {
setIsLoading(true);
return get(`${PLUGIN_ID}/version-form-data/${versionId}${locale ? `?locale=${locale}` : ''}`)
.then((res) => res.data)
.finally(() => {
setIsLoading(false);
});
};
return { prepareVersionFormData, isLoading } as const;
};

View File

@@ -0,0 +1,28 @@
import { useFetchClient } from '@strapi/strapi/admin';
import { useState } from 'react';
import { PLUGIN_ID } from '../pluginId';
import { ContentVersion } from '../types';
type FindVersionProps = {
model: string;
};
export const useVersions = ({ model }: FindVersionProps) => {
const [versions, setVersions] = useState<ContentVersion[]>([]);
const [isLoading, setLoading] = useState<boolean>(false);
const { get } = useFetchClient();
const loadVersions = (contentId: number, onLoad?: (versions: ContentVersion[]) => void) => {
setLoading(true);
get<ContentVersion[]>(`${PLUGIN_ID}/version/${model}/${contentId}`)
.then((res) => {
setVersions(res.data);
onLoad?.(res.data);
})
.finally(() => {
setLoading(false);
});
};
return { versions, setVersions, loadVersions, isLoading } as const;
};

73
admin/src/index.ts Normal file
View File

@@ -0,0 +1,73 @@
import * as yup from 'yup';
import { PLUGIN_ID } from './pluginId';
import { Initializer } from './components/Initializer';
import { VersionPanel } from './components/VersionPanel';
import mutateCTBContentTypeSchema from './mutateCTBContentTypeSchema';
import { CheckboxConfirmation } from './components/CheckboxConfirmation';
import { getTranslation } from './utils/getTranslation';
export default {
register(app: any) {
app.getPlugin('content-manager').apis.addEditViewSidePanel([VersionPanel]);
app.registerPlugin({
id: PLUGIN_ID,
initializer: Initializer,
isReady: false,
name: PLUGIN_ID,
});
},
bootstrap(app: any) {
const ctbPlugin = app.getPlugin('content-type-builder');
if (ctbPlugin) {
const ctbFormsAPI = ctbPlugin.apis.forms;
ctbFormsAPI.addContentTypeSchemaMutation(mutateCTBContentTypeSchema);
ctbFormsAPI.components.add({
id: 'checkboxConfirmation',
component: CheckboxConfirmation,
});
ctbFormsAPI.extendContentType({
validator: () => ({
versions: yup.object().shape({
versioned: yup.bool(),
}),
}),
form: {
advanced() {
return [
{
name: 'pluginOptions.versions.versioned',
description: {
id: getTranslation('plugin.schema.versions.versioned.description-content-type'),
defaultMessage: 'Allow you to keep older versions of content',
},
type: 'checkboxConfirmation',
intlLabel: {
id: getTranslation('plugin.schema.versions.versioned.label-content-type'),
defaultMessage: 'Enable versioning for this Content-Type',
},
},
];
},
},
});
}
},
async registerTrads({ locales }: { locales: string[] }) {
return Promise.all(
locales.map(async (locale) => {
try {
const { default: data } = await import(`./translations/${locale}.json`);
return { data, locale };
} catch {
return { data: {}, locale };
}
})
);
},
};

View File

@@ -0,0 +1,33 @@
import { Schema } from '@strapi/strapi';
import { has, get, omit } from 'lodash';
const versionedPath = ['pluginOptions', 'versions', 'versioned'];
const mutateCTBContentTypeSchema = (
nextSchema: Schema.ContentType,
prevSchema: Schema.ContentType
) => {
// Don't perform mutations components
if (!has(nextSchema, versionedPath)) {
return nextSchema;
}
const isNextSchemaVersioned = get(nextSchema, versionedPath, false);
const isPrevSchemaVersioned = get(prevSchema, ['schema', ...versionedPath], false);
// No need to perform modification on the schema, if versions feature was not changed
// at the ct level
if (isNextSchemaVersioned && isPrevSchemaVersioned) {
return nextSchema;
}
// Remove versions object from the pluginOptions
if (!isNextSchemaVersioned) {
const pluginOptions = omit(nextSchema.pluginOptions, 'versions');
return { ...nextSchema, pluginOptions };
}
return nextSchema;
};
export default mutateCTBContentTypeSchema;

15
admin/src/pages/App.tsx Normal file
View File

@@ -0,0 +1,15 @@
import { Page } from '@strapi/strapi/admin';
import { Routes, Route } from 'react-router-dom';
import { HomePage } from './HomePage';
const App = () => {
return (
<Routes>
<Route index element={<HomePage />} />
<Route path="*" element={<Page.Error />} />
</Routes>
);
};
export { App };

View File

@@ -0,0 +1,16 @@
import { Main } from '@strapi/design-system';
import { useIntl } from 'react-intl';
import { getTranslation } from '../utils/getTranslation';
const HomePage = () => {
const { formatMessage } = useIntl();
return (
<Main>
<h1>Welcome to {formatMessage({ id: getTranslation('plugin.name') })}</h1>
</Main>
);
};
export { HomePage };

1
admin/src/pluginId.ts Normal file
View File

@@ -0,0 +1 @@
export const PLUGIN_ID = 'content-versioning';

View File

@@ -0,0 +1 @@
{}

52
admin/src/types.ts Normal file
View File

@@ -0,0 +1,52 @@
export interface ListViewContext {
/**
* Will be either 'single-types' | 'collection-types'
*/
collectionType: string;
/**
* The current selected documents in the table
*/
documents: Document[];
/**
* The current content-type's model.
*/
model: string;
}
export interface EditViewContext {
/**
* This will only be null if the content-type
* does not have draft & publish enabled.
*/
activeTab: 'draft' | 'published' | null;
/**
* Will be either 'single-types' | 'collection-types'
*/
collectionType: string;
/**
* Will be undefined if someone is creating an entry.
*/
document?: { id: number; locale: string | null } & Record<string, unknown>;
/**
* Will be undefined if someone is creating an entry.
*/
documentId?: string;
/**
* Will be undefined if someone is creating an entry.
*/
meta?: any;
/**
* The current content-type's model.
*/
model: string;
}
export type ContentVersion<T = Record<string, unknown>> = {
documentId: string;
model: string;
createdAt: string;
data: T;
id: number;
};
export type ValueOf<T> = T[keyof T];

View File

@@ -0,0 +1,11 @@
const getRelationLabel = (relation: any, mainField?: any): string => {
const label = mainField && relation[mainField.name] ? relation[mainField.name] : null;
if (typeof label === 'string') {
return label;
}
return relation.documentId;
};
export { getRelationLabel };

View File

@@ -0,0 +1,5 @@
import { PLUGIN_ID } from '../pluginId';
const getTranslation = (id: string) => `${PLUGIN_ID}.${id}`;
export { getTranslation };

10
admin/tsconfig.build.json Normal file
View File

@@ -0,0 +1,10 @@
{
"extends": "./tsconfig",
"include": ["./src", "./custom.d.ts"],
"exclude": ["**/*.test.ts", "**/*.test.tsx"],
"compilerOptions": {
"rootDir": "../",
"baseUrl": ".",
"outDir": "./dist"
}
}

8
admin/tsconfig.json Normal file
View File

@@ -0,0 +1,8 @@
{
"extends": "@strapi/typescript-utils/tsconfigs/admin",
"include": ["./src", "./custom.d.ts", "../scripts"],
"compilerOptions": {
"rootDir": "../",
"baseUrl": "."
}
}

72
package.json Normal file
View File

@@ -0,0 +1,72 @@
{
"version": "0.0.0",
"keywords": [],
"type": "commonjs",
"exports": {
"./package.json": "./package.json",
"./strapi-admin": {
"types": "./dist/admin/src/index.d.ts",
"source": "./admin/src/index.ts",
"import": "./dist/admin/index.mjs",
"require": "./dist/admin/index.js",
"default": "./dist/admin/index.js"
},
"./strapi-server": {
"types": "./dist/server/src/index.d.ts",
"source": "./server/src/index.ts",
"import": "./dist/server/index.mjs",
"require": "./dist/server/index.js",
"default": "./dist/server/index.js"
}
},
"files": [
"dist"
],
"scripts": {
"build": "strapi-plugin build",
"watch": "strapi-plugin watch",
"watch:link": "strapi-plugin watch:link",
"verify": "strapi-plugin verify",
"test:ts:front": "run -T tsc -p admin/tsconfig.json",
"test:ts:back": "run -T tsc -p server/tsconfig.json",
"format": "prettier --write .",
"minify": "node scripts/minify.mjs"
},
"dependencies": {
"@strapi/design-system": "^2.0.0-rc.14",
"@strapi/icons": "^2.0.0-rc.14",
"radash": "^12.1.0",
"react-intl": "^7.0.4"
},
"devDependencies": {
"@strapi/sdk-plugin": "^5.2.8",
"@strapi/strapi": "^5.5.1",
"@strapi/typescript-utils": "^5.5.1",
"@types/react": "^19.0.1",
"@types/react-dom": "^19.0.2",
"javascript-obfuscator": "^4.1.1",
"prettier": "^3.4.2",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.0.2",
"styled-components": "^6.1.13",
"typescript": "^5.7.2"
},
"peerDependencies": {
"@strapi/sdk-plugin": "^5.2.8",
"@strapi/strapi": "^5.5.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.28.0",
"styled-components": "^6.1.13"
},
"strapi": {
"kind": "plugin",
"name": "content-versioning",
"displayName": "content-versioning",
"description": "saves versions of configured collections"
},
"name": "@mibu/content-versioning",
"description": "saves versions of configured collections",
"license": "MIT"
}

41
scripts/minify.mjs Normal file
View File

@@ -0,0 +1,41 @@
// scripts/obfuscate.ts
import JavaScriptObfuscator from 'javascript-obfuscator';
import * as fs from 'fs';
import * as path from 'path';
import { glob } from 'glob';
const obfuscateFile = (filePath) => {
const obfuscationResult = JavaScriptObfuscator.obfuscate(fs.readFileSync(filePath, 'utf8'), {
compact: true,
controlFlowFlattening: false, // Disable control flow flattening
controlFlowFlatteningThreshold: 0,
deadCodeInjection: false, // Disable dead code injection
deadCodeInjectionThreshold: 0,
debugProtection: false,
disableConsoleOutput: true,
identifierNamesGenerator: 'hexadecimal',
log: false,
renameGlobals: false,
selfDefending: false, // Disable self-defending
stringArray: false, // Disable string array
stringArrayEncoding: [],
stringArrayThreshold: 0,
unicodeEscapeSequence: false,
renameProperties: false, // Disable property renaming
renamePropertiesMode: 'safe',
simplify: true, // Enable simplification
transformObjectKeys: false, // Disable object key transformation
unicodeEscapeSequence: false,
});
fs.writeFileSync(filePath, obfuscationResult.getObfuscatedCode());
};
const obfuscateFiles = async (pattern) => {
const files = await glob(pattern);
files.forEach(obfuscateFile);
};
(async () => {
await obfuscateFiles('dist/**/*.{js,mjs}');
})();

5
server/src/bootstrap.ts Normal file
View File

@@ -0,0 +1,5 @@
import type { Core } from '@strapi/strapi';
const bootstrap = ({ strapi }: { strapi: Core.Strapi }) => {};
export default bootstrap;

View File

@@ -0,0 +1,10 @@
import { Config } from '../types';
export default {
default: () => ({ contentVersionLimit: 50 }),
validator: (config: Config) => {
if (typeof config.contentVersionLimit !== 'number') {
throw new Error('contentVersionLimit has to be a boolean');
}
},
};

View File

@@ -0,0 +1,38 @@
export default {
kind: 'collectionType',
collectionName: 'content-version',
info: {
singularName: 'content-version', // kebab-case mandatory
pluralName: 'content-versions', // kebab-case mandatory
displayName: 'Версия контента',
},
pluginOptions: {
'content-manager': {
visible: false,
},
'content-type-builder': {
visible: false,
},
},
options: {
draftAndPublish: false,
},
attributes: {
contentId: {
type: 'string',
required: true,
},
model: {
type: 'string',
required: true,
},
data: {
type: 'json',
required: true,
},
createdAt: {
type: 'datetime',
default: () => new Date(),
},
},
};

View File

@@ -0,0 +1,5 @@
import contentVersion from './content-version';
export default {
'content-version': { schema: contentVersion },
};

View File

@@ -0,0 +1,5 @@
import version from './version';
export default {
version,
};

View File

@@ -0,0 +1,21 @@
import { Core } from '@strapi/strapi';
import { ContentVersionService } from '../services';
import { getService } from '../utils';
const versionController = ({ strapi }: { strapi: Core.Strapi }) => ({
async find(ctx) {
const { getVersions } = getService<ContentVersionService>('contentVersion');
const { model, contentId } = ctx.params;
return getVersions(contentId, model);
},
async getVersionFormData(ctx) {
const { getVersionFormData } = getService<ContentVersionService>('contentVersion');
const { versionId } = ctx.params;
const locale = ctx.request.query['locale'];
return getVersionFormData(versionId, locale);
},
});
export default versionController;

7
server/src/destroy.ts Normal file
View File

@@ -0,0 +1,7 @@
import type { Core } from '@strapi/strapi';
const destroy = ({ strapi }: { strapi: Core.Strapi }) => {
// destroy phase
};
export default destroy;

30
server/src/index.ts Normal file
View File

@@ -0,0 +1,30 @@
/**
* Application methods
*/
import bootstrap from './bootstrap';
import destroy from './destroy';
import register from './register';
/**
* Plugin server methods
*/
import config from './config';
import contentTypes from './content-types';
import controllers from './controllers';
import middlewares from './middlewares';
import policies from './policies';
import routes from './routes';
import services from './services';
export default {
register,
bootstrap,
destroy,
config,
controllers,
routes,
services,
contentTypes,
policies,
middlewares,
};

View File

@@ -0,0 +1,36 @@
import { Core, Internal, UID } from '@strapi/strapi';
import { get } from 'radash';
import { getService } from '../utils';
import { ContentVersionService } from '../services';
type RequestParams = {
model: UID.Schema;
documentId: string;
};
export const deleteWithVersions: Core.MiddlewareHandler = async (ctx, next) => {
const { model, documentId } = get<RequestParams>(ctx, 'request.params');
const modelDef = strapi.getModel(model);
const contentTypeService = getService('contentTypes');
const contentVersionService = getService<ContentVersionService>('contentVersion');
const modelDocumentService = strapi.documents(model as Internal.UID.ContentType);
if (!contentTypeService.isVersionedContentType(modelDef)) {
return next();
}
await strapi.db.transaction(async () => {
const ids = await modelDocumentService
.findMany({
fields: ['id'],
filters: {
documentId,
},
locale: ctx.request.query.locale as string | undefined,
})
.then((data) => data.map(({ id }) => id));
await contentVersionService.deleteVersionsForContent(ids);
return next();
});
};

View File

@@ -0,0 +1,4 @@
export default {};
export * from './publish-with-version';
export * from './delete-with-versions';

View File

@@ -0,0 +1,41 @@
import { Core, Internal, UID } from '@strapi/strapi';
import { get } from 'radash';
import { getDeepPopulate, getService } from '../utils';
import { CreateVersionInput, ContentVersionService } from '../services';
type RequestParams = {
model: UID.Schema;
documentId: string;
};
export const publishWithVersionMiddleware: Core.MiddlewareHandler = async (ctx, next) => {
const { model, documentId } = get<RequestParams>(ctx, 'request.params');
const modelDef = strapi.getModel(model);
const contentTypeService = getService('contentTypes');
const contentVersionService = getService<ContentVersionService>('contentVersion');
const modelDocumentService = strapi.documents(model as Internal.UID.ContentType);
if (!contentTypeService.isVersionedContentType(modelDef)) {
return next();
}
await strapi.db.transaction(async () => {
await next();
const deepPopulate = await getDeepPopulate(model);
const data = await modelDocumentService.findOne({
documentId,
locale: ctx.request.body.locale,
populate: deepPopulate,
});
if (!data) throw new Error(`Empty entry for documentid ${documentId}`);
const versionEntry: CreateVersionInput = {
model: model,
contentId: data.id,
data,
};
await contentVersionService.createVersion(versionEntry);
});
};

1
server/src/pluginId.ts Normal file
View File

@@ -0,0 +1 @@
export const PLUGIN_ID = 'content-versioning';

View File

@@ -0,0 +1 @@
export default {};

30
server/src/register.ts Normal file
View File

@@ -0,0 +1,30 @@
import type { Core } from '@strapi/strapi';
import { deleteWithVersions, publishWithVersionMiddleware } from './middlewares';
const addDeleteWithVersionMiddleware = (strapi: Core.Strapi) =>
strapi.server.router.use('/content-manager/collection-types/:model/:documentId', (ctx, next) => {
if (ctx.method === 'DELETE') {
return deleteWithVersions(ctx, next);
}
return next();
});
const addPublishWithVersionMiddleware = (strapi: Core.Strapi) =>
strapi.server.router.use(
'/content-manager/collection-types/:model/:documentId/actions/publish',
(ctx, next) => {
if (ctx.method === 'POST') {
return publishWithVersionMiddleware(ctx, next);
}
return next();
}
);
const register = ({ strapi }: { strapi: Core.Strapi }) => {
addPublishWithVersionMiddleware(strapi);
addDeleteWithVersionMiddleware(strapi);
};
export default register;

View File

@@ -0,0 +1,12 @@
export default [
{
method: 'GET',
path: '/version/:model/:contentId',
handler: 'version.find',
},
{
method: 'GET',
path: '/version-form-data/:versionId',
handler: 'version.getVersionFormData',
},
];

View File

@@ -0,0 +1,10 @@
import { get } from 'radash';
const hasVersionedOption = (modelOrAttribute) =>
get(modelOrAttribute, 'pluginOptions.versions.versioned', false);
export const isVersionedContentType = (model) => hasVersionedOption(model);
export default {
isVersionedContentType,
};

View File

@@ -0,0 +1,110 @@
import type { Core } from '@strapi/strapi';
import { PLUGIN_ID } from '../pluginId';
import { Config, ContentVersion } from '../types';
import { getConfig, getDeepPopulate, prepareVersionFormData } from '../utils';
import { get } from 'radash';
export const CONTENT_VERSION_ID = `${PLUGIN_ID}.content-version`;
export const PLUGIN_CONTENT_VERSION_ID = `plugin::${CONTENT_VERSION_ID}`;
export type CreateVersionInput = Pick<ContentVersion, 'contentId' | 'data' | 'model'>;
export type ContentVersionService = ReturnType<typeof contentVersionService>;
const contentVersionService = ({ strapi }: { strapi: Core.Strapi }) => ({
async createVersion(data: CreateVersionInput) {
return await strapi.db.transaction(async () => {
const config = getConfig();
const contentVersionLimit = config<Config['contentVersionLimit']>('contentVersionLimit');
const newVersion = await strapi.query(PLUGIN_CONTENT_VERSION_ID).create({ data });
const allVersions = await strapi.query(PLUGIN_CONTENT_VERSION_ID).findMany({
select: 'id',
where: { contentId: data.contentId },
orderBy: { createdAt: 'desc' },
});
if (allVersions.length > contentVersionLimit) {
await strapi.query(PLUGIN_CONTENT_VERSION_ID).deleteMany({
where: {
id: {
$in: allVersions.slice(contentVersionLimit).map((item) => get(item, 'id')),
},
},
});
}
return newVersion;
});
},
async getVersions(
contentId: CreateVersionInput['contentId'],
model: CreateVersionInput['model']
) {
return strapi.query(PLUGIN_CONTENT_VERSION_ID).findMany({
select: ['id', 'contentId', 'documentId', 'createdAt'],
where: { contentId, model },
orderBy: { createdAt: 'desc' },
});
},
async getVersion(versionId: string) {
return strapi.query(PLUGIN_CONTENT_VERSION_ID).findOne({
where: { id: versionId },
});
},
async deleteVersionsForContent(contentIds: CreateVersionInput['contentId'][]) {
return strapi.query(PLUGIN_CONTENT_VERSION_ID).deleteMany({
where: {
contentId: {
$in: contentIds,
},
},
});
},
async getVersionFormData(versionId: string, locale: string = null) {
const version: ContentVersion = await strapi
.query(PLUGIN_CONTENT_VERSION_ID)
.findOne({ where: { id: versionId } });
if (!version) throw new Error(`version with id ${versionId} not found`);
const { model, data, contentId } = version;
const contentTypesService = strapi.plugin('content-manager').services['content-types'];
const contentTypeSchema = strapi.getModel(model);
const componentsSchema = strapi.components;
const deepPopulate = await getDeepPopulate(model);
const [contentTypeSettings, componentsSettings, currentData] = await Promise.all([
contentTypesService.findConfiguration(contentTypeSchema),
contentTypesService.findComponentsConfigurations(contentTypeSchema),
strapi.db
.query(model)
.findOne({ where: { id: contentId }, select: ['documentId'] })
.then(({ documentId }) =>
//@ts-expect-error
strapi.documents(model).findOne({
populate: deepPopulate,
locale,
documentId,
})
),
]);
const result = await prepareVersionFormData({
schema: contentTypeSchema,
configuration: contentTypeSettings,
componentsConfiguration: componentsSettings,
componentsSchema,
versionData: data,
currentData,
locale,
});
return result;
},
});
export default contentVersionService;

View File

@@ -0,0 +1,9 @@
import contentTypes from './content-types';
import contentVersion from './content-version';
export { type ContentVersionService, CreateVersionInput } from './content-version';
export default {
contentVersion,
contentTypes,
};

46
server/src/types.ts Normal file
View File

@@ -0,0 +1,46 @@
import { UID } from '@strapi/strapi';
export type ContentVersion<T = Record<string, unknown>> = {
documentId: string;
contentId: number;
model: UID.Schema;
createdAt: string;
data: T;
id: number;
};
export type FormRelationFieldAttributes = {
id: number;
documentId: string;
locale: string | null;
};
export type FormRelationConnection = FormRelationFieldAttributes & {
href?: string;
label: string;
apiData?: FormRelationFieldAttributes;
};
export type FormRelation = {
connect?: FormRelationConnection[];
disconnect?: FormRelationConnection[];
};
export type Metadata = {
edit: {
label: string;
description: string;
placeholder: string;
visible: boolean;
editable: boolean;
mainField?: string;
};
};
export type Configuration = {
metadatas: Record<string, Metadata>;
};
export type Config = {
contentVersionLimit: number;
};

View File

@@ -0,0 +1,9 @@
export const getRelationLabel = (content: Record<string, unknown>, mainField?: string): string => {
const label = mainField && content[mainField] ? content[mainField] : null;
if (typeof label === 'string') {
return label;
}
return content.documentId as string;
};

16
server/src/utils/index.ts Normal file
View File

@@ -0,0 +1,16 @@
import { Core, UID } from '@strapi/strapi';
import { PLUGIN_ID } from '../pluginId';
export * from './prepareVersionFormData';
export * from './getRelationLabel';
export const getService = <T = Core.Service>(name: string) =>
strapi.plugin(PLUGIN_ID).service(name) as T;
export const getConfig = () => strapi.plugin(PLUGIN_ID).config;
export const getDeepPopulate = async (model: UID.Schema) =>
// @ts-expect-error
strapi.plugin('content-manager').services['populate-builder'](model).populateDeep().build();
export default {};

View File

@@ -0,0 +1,5 @@
import { UID } from '@strapi/strapi';
export const isLocalizedContentType = (model: UID.Schema) => {
return strapi.plugin('i18n')?.service('content-types')?.isLocalizedContentType(model);
};

View File

@@ -0,0 +1,223 @@
import {Schema, UID} from '@strapi/strapi';
import {chain, get, isArray, isEmpty, isEqual, isNumber, pick} from 'radash';
import {Configuration, Metadata, FormRelationConnection, FormRelation} from '../types';
import {getRelationLabel} from './getRelationLabel';
import {withTempKeys} from './withTempKeys';
type PrepareFormDataProps = {
currentData: unknown;
versionData: unknown;
schema: Schema.ContentType | Schema.Component;
componentsSchema?: Schema.Components;
configuration: Configuration;
componentsConfiguration?: Record<UID.Component, Configuration>;
locale: string | null;
};
type RelationValue = {id: number; documentId: string} & Record<string, unknown>;
type PrepareRelationProps = {
metadata?: Metadata;
currentData?: RelationValue | RelationValue[];
versionData?: RelationValue | RelationValue[];
locale: string | null;
};
const toArray = <T>(data?: T | T[]) => (isArray(data) ? data : [data]);
const isEqualIds = (prev?: unknown, curr?: unknown) => {
const currId = get(curr, 'id')
const prevId = get(prev, 'id')
return isNumber(prevId) && isNumber(currId) && isEqual(currId, prevId)
}
const makeConnection = (
relation: RelationValue,
locale: string | null,
mainField?: string
): FormRelationConnection => {
const mainIdentifiers = pick(relation, ['documentId', 'id']);
return {
...mainIdentifiers,
apiData: {
...mainIdentifiers,
locale,
},
[mainField ?? 'documentId']: relation[mainField ?? 'documentId'],
label: getRelationLabel(relation, mainField),
locale,
};
};
const fetchFreshRelationData = (
data: RelationValue | RelationValue[],
schema: Schema.Attribute.Relation,
locale: string | null,
metadata?: Metadata
) => {
const payload = data
? toArray(data)
.map((item: RelationValue) => get(item, 'id'))
.filter(Boolean)
: [];
return !isEmpty(payload)
? strapi
// @ts-expect-error
.documents(schema.target)
.findMany({
locale,
filters: {id: {$in: payload}},
fields: ['id', 'documentId', metadata?.edit?.mainField].filter(Boolean),
})
: [];
};
const notIncludesRelation = (oldVal: RelationValue[]) => (newVal: RelationValue) =>
oldVal.every(({id}) => newVal.id !== id);
const prepareRelation = ({
currentData = [],
versionData = [],
metadata,
locale,
}: PrepareRelationProps): FormRelation => {
const currentRelationsArray = toArray(currentData).filter((relation) => !!relation?.id);
const versionRelationArray = toArray(versionData).filter((relation) => !!relation?.id);
if (isEmpty(currentData) && isEmpty(versionData)) return {connect: [], disconnect: []};
const relationsToConnect = versionRelationArray.filter(
notIncludesRelation(currentRelationsArray)
);
const relationsToDisconnect = currentRelationsArray.filter(
notIncludesRelation(versionRelationArray)
);
const mainField = get<string | undefined>(metadata, 'edit.mainField');
const connect: FormRelationConnection[] = relationsToConnect.map((relation) =>
makeConnection(relation, locale, mainField)
);
const disconnect: FormRelationConnection[] = relationsToDisconnect.map((relation) =>
makeConnection(relation, locale, mainField)
);
return {connect, disconnect};
};
export const prepareVersionFormData = async ({
componentsConfiguration,
componentsSchema,
locale,
...otherProps
}: PrepareFormDataProps) => {
const _prepareVersionFormData = async ({
schema,
configuration,
currentData,
versionData,
}: Omit<PrepareFormDataProps, 'componentsSchema' | 'componentsConfiguration' | 'locale'>) => {
if (!schema || isEmpty(schema)) throw new Error('Schema was not provided');
const result = pick(versionData as any, ['__component', isEqualIds(currentData, versionData) && 'id']);
for (const [attributeKey, attributeValue] of Object.entries(schema.attributes)) {
if (attributeValue.writable === false) {
continue;
}
switch (attributeValue.type) {
case 'relation':
const relationConfiguration = configuration?.metadatas?.[attributeKey];
const freshVersionRelations = await fetchFreshRelationData(
get(versionData, attributeKey),
attributeValue,
locale,
relationConfiguration
);
const relationField = prepareRelation({
currentData: get(currentData, attributeKey),
versionData: freshVersionRelations,
metadata: relationConfiguration,
locale,
});
Object.assign(result, {[attributeKey]: relationField});
break;
case 'dynamiczone':
const currentDynamicZone = get<any[]>(currentData, attributeKey);
const versionDynamicZone = get<any[]>(versionData, attributeKey).filter(
({__component}) => !!componentsSchema[__component]
);
const dynamicZoneField = await Promise.all(
versionDynamicZone.map((data) =>
_prepareVersionFormData({
currentData: currentDynamicZone?.find(
({__component}) => __component === data.__component
),
versionData: data,
schema: componentsSchema[data.__component],
configuration: componentsConfiguration?.[data.__component],
})
)
).then(chain((res) => res.filter((item) => !isEmpty(item)), withTempKeys));
if (!isEmpty(dynamicZoneField)) {
Object.assign(result, {[attributeKey]: dynamicZoneField});
}
break;
case 'component':
const component = get<string>(attributeValue, 'component');
const versionComponent = get(versionData, attributeKey);
if (!versionComponent || isEmpty(versionComponent)) {
break;
}
const request = {
schema: componentsSchema?.[component],
configuration: componentsConfiguration?.[component],
locale,
};
const componentField = attributeValue.repeatable
? await Promise.all(
toArray(versionComponent).map((data: any) =>
_prepareVersionFormData({
...request,
versionData: data,
currentData: toArray(currentData?.[attributeKey]).find(
chain(
(item) => get(item, 'id'),
(id) => isEqual(id, data.id)
)
),
})
)
).then(withTempKeys)
: await _prepareVersionFormData({
...request,
versionData: versionComponent,
currentData: currentData?.[attributeKey],
});
if (!isEmpty(componentField)) {
Object.assign(result, {
[attributeKey]: componentField,
});
}
break;
default:
if (versionData?.[attributeKey] !== undefined) {
Object.assign(result, {[attributeKey]: versionData[attributeKey]});
}
}
}
return result;
};
return _prepareVersionFormData(otherProps);
};

View File

@@ -0,0 +1,7 @@
import { generateNKeysBetween } from 'fractional-indexing';
import { assign } from 'radash';
export const withTempKeys = (array: unknown[]) => {
const keys = generateNKeysBetween(undefined, undefined, array.length);
return array.map((item, index) => assign(item, { __temp_key__: keys[index] }));
};

View File

@@ -0,0 +1,10 @@
{
"extends": "./tsconfig",
"include": ["./src"],
"exclude": ["**/*.test.ts"],
"compilerOptions": {
"rootDir": "../",
"baseUrl": ".",
"outDir": "./dist"
}
}

8
server/tsconfig.json Normal file
View File

@@ -0,0 +1,8 @@
{
"extends": "@strapi/typescript-utils/tsconfigs/server",
"include": ["./src"],
"compilerOptions": {
"rootDir": "../",
"baseUrl": "."
}
}