init
This commit is contained in:
51
.gitignore
vendored
Normal file
51
.gitignore
vendored
Normal 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
6
.npmrc
Normal 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
22
README.md
Normal 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
2
admin/custom.d.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
declare module '@strapi/design-system/*';
|
||||||
|
declare module '@strapi/design-system';
|
||||||
127
admin/src/components/CheckboxConfirmation.tsx
Normal file
127
admin/src/components/CheckboxConfirmation.tsx
Normal 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 };
|
||||||
19
admin/src/components/Initializer.tsx
Normal file
19
admin/src/components/Initializer.tsx
Normal 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 };
|
||||||
5
admin/src/components/PluginIcon.tsx
Normal file
5
admin/src/components/PluginIcon.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { PuzzlePiece } from '@strapi/icons';
|
||||||
|
|
||||||
|
const PluginIcon = () => <PuzzlePiece />;
|
||||||
|
|
||||||
|
export { PluginIcon };
|
||||||
45
admin/src/components/VersionCard.tsx
Normal file
45
admin/src/components/VersionCard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
78
admin/src/components/VersionList.tsx
Normal file
78
admin/src/components/VersionList.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
16
admin/src/components/VersionPanel.tsx
Normal file
16
admin/src/components/VersionPanel.tsx
Normal 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
1
admin/src/consts.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export const PUBLISHED_AT_ATTRIBUTE_NAME = 'publishedAt';
|
||||||
20
admin/src/hooks/usePrepareVersionFormData.ts
Normal file
20
admin/src/hooks/usePrepareVersionFormData.ts
Normal 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;
|
||||||
|
};
|
||||||
28
admin/src/hooks/useVersions.ts
Normal file
28
admin/src/hooks/useVersions.ts
Normal 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
73
admin/src/index.ts
Normal 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 };
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
33
admin/src/mutateCTBContentTypeSchema.ts
Normal file
33
admin/src/mutateCTBContentTypeSchema.ts
Normal 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
15
admin/src/pages/App.tsx
Normal 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 };
|
||||||
16
admin/src/pages/HomePage.tsx
Normal file
16
admin/src/pages/HomePage.tsx
Normal 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
1
admin/src/pluginId.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export const PLUGIN_ID = 'content-versioning';
|
||||||
1
admin/src/translations/en.json
Normal file
1
admin/src/translations/en.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{}
|
||||||
52
admin/src/types.ts
Normal file
52
admin/src/types.ts
Normal 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];
|
||||||
11
admin/src/utils/getRelationLabel.ts
Normal file
11
admin/src/utils/getRelationLabel.ts
Normal 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 };
|
||||||
5
admin/src/utils/getTranslation.ts
Normal file
5
admin/src/utils/getTranslation.ts
Normal 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
10
admin/tsconfig.build.json
Normal 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
8
admin/tsconfig.json
Normal 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
72
package.json
Normal 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
41
scripts/minify.mjs
Normal 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
5
server/src/bootstrap.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import type { Core } from '@strapi/strapi';
|
||||||
|
|
||||||
|
const bootstrap = ({ strapi }: { strapi: Core.Strapi }) => {};
|
||||||
|
|
||||||
|
export default bootstrap;
|
||||||
10
server/src/config/index.ts
Normal file
10
server/src/config/index.ts
Normal 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');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
38
server/src/content-types/content-version.ts
Normal file
38
server/src/content-types/content-version.ts
Normal 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(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
5
server/src/content-types/index.ts
Normal file
5
server/src/content-types/index.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import contentVersion from './content-version';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
'content-version': { schema: contentVersion },
|
||||||
|
};
|
||||||
5
server/src/controllers/index.ts
Normal file
5
server/src/controllers/index.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import version from './version';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
version,
|
||||||
|
};
|
||||||
21
server/src/controllers/version.ts
Normal file
21
server/src/controllers/version.ts
Normal 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
7
server/src/destroy.ts
Normal 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
30
server/src/index.ts
Normal 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,
|
||||||
|
};
|
||||||
36
server/src/middlewares/delete-with-versions.ts
Normal file
36
server/src/middlewares/delete-with-versions.ts
Normal 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();
|
||||||
|
});
|
||||||
|
};
|
||||||
4
server/src/middlewares/index.ts
Normal file
4
server/src/middlewares/index.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export default {};
|
||||||
|
|
||||||
|
export * from './publish-with-version';
|
||||||
|
export * from './delete-with-versions';
|
||||||
41
server/src/middlewares/publish-with-version.ts
Normal file
41
server/src/middlewares/publish-with-version.ts
Normal 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
1
server/src/pluginId.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export const PLUGIN_ID = 'content-versioning';
|
||||||
1
server/src/policies/index.ts
Normal file
1
server/src/policies/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export default {};
|
||||||
30
server/src/register.ts
Normal file
30
server/src/register.ts
Normal 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;
|
||||||
12
server/src/routes/index.ts
Normal file
12
server/src/routes/index.ts
Normal 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',
|
||||||
|
},
|
||||||
|
];
|
||||||
10
server/src/services/content-types.ts
Normal file
10
server/src/services/content-types.ts
Normal 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,
|
||||||
|
};
|
||||||
110
server/src/services/content-version.ts
Normal file
110
server/src/services/content-version.ts
Normal 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;
|
||||||
9
server/src/services/index.ts
Normal file
9
server/src/services/index.ts
Normal 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
46
server/src/types.ts
Normal 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;
|
||||||
|
};
|
||||||
9
server/src/utils/getRelationLabel.ts
Normal file
9
server/src/utils/getRelationLabel.ts
Normal 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
16
server/src/utils/index.ts
Normal 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 {};
|
||||||
5
server/src/utils/isLocalizedContentType.ts
Normal file
5
server/src/utils/isLocalizedContentType.ts
Normal 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);
|
||||||
|
};
|
||||||
223
server/src/utils/prepareVersionFormData.ts
Normal file
223
server/src/utils/prepareVersionFormData.ts
Normal 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);
|
||||||
|
};
|
||||||
7
server/src/utils/withTempKeys.ts
Normal file
7
server/src/utils/withTempKeys.ts
Normal 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] }));
|
||||||
|
};
|
||||||
10
server/tsconfig.build.json
Normal file
10
server/tsconfig.build.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig",
|
||||||
|
"include": ["./src"],
|
||||||
|
"exclude": ["**/*.test.ts"],
|
||||||
|
"compilerOptions": {
|
||||||
|
"rootDir": "../",
|
||||||
|
"baseUrl": ".",
|
||||||
|
"outDir": "./dist"
|
||||||
|
}
|
||||||
|
}
|
||||||
8
server/tsconfig.json
Normal file
8
server/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": "@strapi/typescript-utils/tsconfigs/server",
|
||||||
|
"include": ["./src"],
|
||||||
|
"compilerOptions": {
|
||||||
|
"rootDir": "../",
|
||||||
|
"baseUrl": "."
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user