From 2b72f5407b72e0e085367fa41d9a838ea5bc724d Mon Sep 17 00:00:00 2001 From: Salam-vsem Date: Sat, 24 May 2025 19:02:39 +0300 Subject: [PATCH] init --- .gitignore | 51 ++++ .npmrc | 6 + README.md | 22 ++ admin/custom.d.ts | 2 + admin/src/components/CheckboxConfirmation.tsx | 127 ++++++++++ admin/src/components/Initializer.tsx | 19 ++ admin/src/components/PluginIcon.tsx | 5 + admin/src/components/VersionCard.tsx | 45 ++++ admin/src/components/VersionList.tsx | 78 ++++++ admin/src/components/VersionPanel.tsx | 16 ++ admin/src/consts.ts | 1 + admin/src/hooks/usePrepareVersionFormData.ts | 20 ++ admin/src/hooks/useVersions.ts | 28 +++ admin/src/index.ts | 73 ++++++ admin/src/mutateCTBContentTypeSchema.ts | 33 +++ admin/src/pages/App.tsx | 15 ++ admin/src/pages/HomePage.tsx | 16 ++ admin/src/pluginId.ts | 1 + admin/src/translations/en.json | 1 + admin/src/types.ts | 52 ++++ admin/src/utils/getRelationLabel.ts | 11 + admin/src/utils/getTranslation.ts | 5 + admin/tsconfig.build.json | 10 + admin/tsconfig.json | 8 + package.json | 72 ++++++ scripts/minify.mjs | 41 ++++ server/src/bootstrap.ts | 5 + server/src/config/index.ts | 10 + server/src/content-types/content-version.ts | 38 +++ server/src/content-types/index.ts | 5 + server/src/controllers/index.ts | 5 + server/src/controllers/version.ts | 21 ++ server/src/destroy.ts | 7 + server/src/index.ts | 30 +++ .../src/middlewares/delete-with-versions.ts | 36 +++ server/src/middlewares/index.ts | 4 + .../src/middlewares/publish-with-version.ts | 41 ++++ server/src/pluginId.ts | 1 + server/src/policies/index.ts | 1 + server/src/register.ts | 30 +++ server/src/routes/index.ts | 12 + server/src/services/content-types.ts | 10 + server/src/services/content-version.ts | 110 +++++++++ server/src/services/index.ts | 9 + server/src/types.ts | 46 ++++ server/src/utils/getRelationLabel.ts | 9 + server/src/utils/index.ts | 16 ++ server/src/utils/isLocalizedContentType.ts | 5 + server/src/utils/prepareVersionFormData.ts | 223 ++++++++++++++++++ server/src/utils/withTempKeys.ts | 7 + server/tsconfig.build.json | 10 + server/tsconfig.json | 8 + 52 files changed, 1457 insertions(+) create mode 100644 .gitignore create mode 100644 .npmrc create mode 100644 README.md create mode 100644 admin/custom.d.ts create mode 100644 admin/src/components/CheckboxConfirmation.tsx create mode 100644 admin/src/components/Initializer.tsx create mode 100644 admin/src/components/PluginIcon.tsx create mode 100644 admin/src/components/VersionCard.tsx create mode 100644 admin/src/components/VersionList.tsx create mode 100644 admin/src/components/VersionPanel.tsx create mode 100644 admin/src/consts.ts create mode 100644 admin/src/hooks/usePrepareVersionFormData.ts create mode 100644 admin/src/hooks/useVersions.ts create mode 100644 admin/src/index.ts create mode 100644 admin/src/mutateCTBContentTypeSchema.ts create mode 100644 admin/src/pages/App.tsx create mode 100644 admin/src/pages/HomePage.tsx create mode 100644 admin/src/pluginId.ts create mode 100644 admin/src/translations/en.json create mode 100644 admin/src/types.ts create mode 100644 admin/src/utils/getRelationLabel.ts create mode 100644 admin/src/utils/getTranslation.ts create mode 100644 admin/tsconfig.build.json create mode 100644 admin/tsconfig.json create mode 100644 package.json create mode 100644 scripts/minify.mjs create mode 100644 server/src/bootstrap.ts create mode 100644 server/src/config/index.ts create mode 100644 server/src/content-types/content-version.ts create mode 100644 server/src/content-types/index.ts create mode 100644 server/src/controllers/index.ts create mode 100644 server/src/controllers/version.ts create mode 100644 server/src/destroy.ts create mode 100644 server/src/index.ts create mode 100644 server/src/middlewares/delete-with-versions.ts create mode 100644 server/src/middlewares/index.ts create mode 100644 server/src/middlewares/publish-with-version.ts create mode 100644 server/src/pluginId.ts create mode 100644 server/src/policies/index.ts create mode 100644 server/src/register.ts create mode 100644 server/src/routes/index.ts create mode 100644 server/src/services/content-types.ts create mode 100644 server/src/services/content-version.ts create mode 100644 server/src/services/index.ts create mode 100644 server/src/types.ts create mode 100644 server/src/utils/getRelationLabel.ts create mode 100644 server/src/utils/index.ts create mode 100644 server/src/utils/isLocalizedContentType.ts create mode 100644 server/src/utils/prepareVersionFormData.ts create mode 100644 server/src/utils/withTempKeys.ts create mode 100644 server/tsconfig.build.json create mode 100644 server/tsconfig.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..03726d7 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..0851bbf --- /dev/null +++ b/.npmrc @@ -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/ \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..b9a18db --- /dev/null +++ b/README.md @@ -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 + } + } +}); +``` diff --git a/admin/custom.d.ts b/admin/custom.d.ts new file mode 100644 index 0000000..f5d1b28 --- /dev/null +++ b/admin/custom.d.ts @@ -0,0 +1,2 @@ +declare module '@strapi/design-system/*'; +declare module '@strapi/design-system'; diff --git a/admin/src/components/CheckboxConfirmation.tsx b/admin/src/components/CheckboxConfirmation.tsx new file mode 100644 index 0000000..e62922c --- /dev/null +++ b/admin/src/components/CheckboxConfirmation.tsx @@ -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 ( + + + + {label} + + + + + + {formatMessage({ + id: getTranslation('CheckboxConfirmation.Modal.title'), + defaultMessage: 'Disable versioning', + })} + + }> + + + + {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).', + })} + + + + + {formatMessage({ + id: getTranslation('CheckboxConfirmation.Modal.body'), + defaultMessage: 'Do you want to disable it?', + })} + + + + + + + + + + + + + + + ); +}; + +export { CheckboxConfirmation }; diff --git a/admin/src/components/Initializer.tsx b/admin/src/components/Initializer.tsx new file mode 100644 index 0000000..8188291 --- /dev/null +++ b/admin/src/components/Initializer.tsx @@ -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 }; diff --git a/admin/src/components/PluginIcon.tsx b/admin/src/components/PluginIcon.tsx new file mode 100644 index 0000000..54174ba --- /dev/null +++ b/admin/src/components/PluginIcon.tsx @@ -0,0 +1,5 @@ +import { PuzzlePiece } from '@strapi/icons'; + +const PluginIcon = () => ; + +export { PluginIcon }; diff --git a/admin/src/components/VersionCard.tsx b/admin/src/components/VersionCard.tsx new file mode 100644 index 0000000..01a92e0 --- /dev/null +++ b/admin/src/components/VersionCard.tsx @@ -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 = ({ version, onSelect, isSelected, loading }) => { + return ( + + + + + {new Date(version.createdAt).toLocaleString()} + + + + + + ); +}; diff --git a/admin/src/components/VersionList.tsx b/admin/src/components/VersionList.tsx new file mode 100644 index 0000000..6240321 --- /dev/null +++ b/admin/src/components/VersionList.tsx @@ -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 = ({ + model, + meta, + document, +}: EditViewContext) => { + const [selectedVersion, setSelectedVersion] = useState(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) => doc[PUBLISHED_AT_ATTRIBUTE_NAME] !== null + )) && + document?.status !== 'modified'; + + const shouldRefetchVersions = isDocumentPublished && !ctx.form.isSubmitting; + + useEffect(() => { + if (shouldRefetchVersions) { + handleLoadVersions(); + } + }, [shouldRefetchVersions, handleLoadVersions]); + + return isLoading ? ( + + ) : versions.length > 0 ? ( + + {versions.map((version) => ( + + ))} + + ) : ( + No versions available. + ); +}; diff --git a/admin/src/components/VersionPanel.tsx b/admin/src/components/VersionPanel.tsx new file mode 100644 index 0000000..ff7e832 --- /dev/null +++ b/admin/src/components/VersionPanel.tsx @@ -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: , + }; +}; diff --git a/admin/src/consts.ts b/admin/src/consts.ts new file mode 100644 index 0000000..f67cb26 --- /dev/null +++ b/admin/src/consts.ts @@ -0,0 +1 @@ +export const PUBLISHED_AT_ATTRIBUTE_NAME = 'publishedAt'; diff --git a/admin/src/hooks/usePrepareVersionFormData.ts b/admin/src/hooks/usePrepareVersionFormData.ts new file mode 100644 index 0000000..84cb571 --- /dev/null +++ b/admin/src/hooks/usePrepareVersionFormData.ts @@ -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; +}; diff --git a/admin/src/hooks/useVersions.ts b/admin/src/hooks/useVersions.ts new file mode 100644 index 0000000..6bf0197 --- /dev/null +++ b/admin/src/hooks/useVersions.ts @@ -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([]); + const [isLoading, setLoading] = useState(false); + const { get } = useFetchClient(); + + const loadVersions = (contentId: number, onLoad?: (versions: ContentVersion[]) => void) => { + setLoading(true); + get(`${PLUGIN_ID}/version/${model}/${contentId}`) + .then((res) => { + setVersions(res.data); + onLoad?.(res.data); + }) + .finally(() => { + setLoading(false); + }); + }; + + return { versions, setVersions, loadVersions, isLoading } as const; +}; diff --git a/admin/src/index.ts b/admin/src/index.ts new file mode 100644 index 0000000..89a1b18 --- /dev/null +++ b/admin/src/index.ts @@ -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 }; + } + }) + ); + }, +}; diff --git a/admin/src/mutateCTBContentTypeSchema.ts b/admin/src/mutateCTBContentTypeSchema.ts new file mode 100644 index 0000000..bda153f --- /dev/null +++ b/admin/src/mutateCTBContentTypeSchema.ts @@ -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; diff --git a/admin/src/pages/App.tsx b/admin/src/pages/App.tsx new file mode 100644 index 0000000..c77159c --- /dev/null +++ b/admin/src/pages/App.tsx @@ -0,0 +1,15 @@ +import { Page } from '@strapi/strapi/admin'; +import { Routes, Route } from 'react-router-dom'; + +import { HomePage } from './HomePage'; + +const App = () => { + return ( + + } /> + } /> + + ); +}; + +export { App }; diff --git a/admin/src/pages/HomePage.tsx b/admin/src/pages/HomePage.tsx new file mode 100644 index 0000000..bff4f4e --- /dev/null +++ b/admin/src/pages/HomePage.tsx @@ -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 ( +
+

Welcome to {formatMessage({ id: getTranslation('plugin.name') })}

+
+ ); +}; + +export { HomePage }; diff --git a/admin/src/pluginId.ts b/admin/src/pluginId.ts new file mode 100644 index 0000000..c91b211 --- /dev/null +++ b/admin/src/pluginId.ts @@ -0,0 +1 @@ +export const PLUGIN_ID = 'content-versioning'; diff --git a/admin/src/translations/en.json b/admin/src/translations/en.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/admin/src/translations/en.json @@ -0,0 +1 @@ +{} diff --git a/admin/src/types.ts b/admin/src/types.ts new file mode 100644 index 0000000..272abe3 --- /dev/null +++ b/admin/src/types.ts @@ -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; + /** + * 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> = { + documentId: string; + model: string; + createdAt: string; + data: T; + id: number; +}; + +export type ValueOf = T[keyof T]; diff --git a/admin/src/utils/getRelationLabel.ts b/admin/src/utils/getRelationLabel.ts new file mode 100644 index 0000000..b02a284 --- /dev/null +++ b/admin/src/utils/getRelationLabel.ts @@ -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 }; diff --git a/admin/src/utils/getTranslation.ts b/admin/src/utils/getTranslation.ts new file mode 100644 index 0000000..e2e57f7 --- /dev/null +++ b/admin/src/utils/getTranslation.ts @@ -0,0 +1,5 @@ +import { PLUGIN_ID } from '../pluginId'; + +const getTranslation = (id: string) => `${PLUGIN_ID}.${id}`; + +export { getTranslation }; diff --git a/admin/tsconfig.build.json b/admin/tsconfig.build.json new file mode 100644 index 0000000..d033e0c --- /dev/null +++ b/admin/tsconfig.build.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig", + "include": ["./src", "./custom.d.ts"], + "exclude": ["**/*.test.ts", "**/*.test.tsx"], + "compilerOptions": { + "rootDir": "../", + "baseUrl": ".", + "outDir": "./dist" + } +} diff --git a/admin/tsconfig.json b/admin/tsconfig.json new file mode 100644 index 0000000..a1f6c2f --- /dev/null +++ b/admin/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "@strapi/typescript-utils/tsconfigs/admin", + "include": ["./src", "./custom.d.ts", "../scripts"], + "compilerOptions": { + "rootDir": "../", + "baseUrl": "." + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..49cac5d --- /dev/null +++ b/package.json @@ -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" +} diff --git a/scripts/minify.mjs b/scripts/minify.mjs new file mode 100644 index 0000000..0d389dd --- /dev/null +++ b/scripts/minify.mjs @@ -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}'); +})(); diff --git a/server/src/bootstrap.ts b/server/src/bootstrap.ts new file mode 100644 index 0000000..50c249e --- /dev/null +++ b/server/src/bootstrap.ts @@ -0,0 +1,5 @@ +import type { Core } from '@strapi/strapi'; + +const bootstrap = ({ strapi }: { strapi: Core.Strapi }) => {}; + +export default bootstrap; diff --git a/server/src/config/index.ts b/server/src/config/index.ts new file mode 100644 index 0000000..5dd115c --- /dev/null +++ b/server/src/config/index.ts @@ -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'); + } + }, +}; diff --git a/server/src/content-types/content-version.ts b/server/src/content-types/content-version.ts new file mode 100644 index 0000000..c5e8356 --- /dev/null +++ b/server/src/content-types/content-version.ts @@ -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(), + }, + }, +}; diff --git a/server/src/content-types/index.ts b/server/src/content-types/index.ts new file mode 100644 index 0000000..bc4aabb --- /dev/null +++ b/server/src/content-types/index.ts @@ -0,0 +1,5 @@ +import contentVersion from './content-version'; + +export default { + 'content-version': { schema: contentVersion }, +}; diff --git a/server/src/controllers/index.ts b/server/src/controllers/index.ts new file mode 100644 index 0000000..4c631d1 --- /dev/null +++ b/server/src/controllers/index.ts @@ -0,0 +1,5 @@ +import version from './version'; + +export default { + version, +}; diff --git a/server/src/controllers/version.ts b/server/src/controllers/version.ts new file mode 100644 index 0000000..ad7b814 --- /dev/null +++ b/server/src/controllers/version.ts @@ -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('contentVersion'); + const { model, contentId } = ctx.params; + + return getVersions(contentId, model); + }, + async getVersionFormData(ctx) { + const { getVersionFormData } = getService('contentVersion'); + const { versionId } = ctx.params; + const locale = ctx.request.query['locale']; + + return getVersionFormData(versionId, locale); + }, +}); + +export default versionController; diff --git a/server/src/destroy.ts b/server/src/destroy.ts new file mode 100644 index 0000000..d33d272 --- /dev/null +++ b/server/src/destroy.ts @@ -0,0 +1,7 @@ +import type { Core } from '@strapi/strapi'; + +const destroy = ({ strapi }: { strapi: Core.Strapi }) => { + // destroy phase +}; + +export default destroy; diff --git a/server/src/index.ts b/server/src/index.ts new file mode 100644 index 0000000..733b4fe --- /dev/null +++ b/server/src/index.ts @@ -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, +}; diff --git a/server/src/middlewares/delete-with-versions.ts b/server/src/middlewares/delete-with-versions.ts new file mode 100644 index 0000000..be15bcd --- /dev/null +++ b/server/src/middlewares/delete-with-versions.ts @@ -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(ctx, 'request.params'); + const modelDef = strapi.getModel(model); + const contentTypeService = getService('contentTypes'); + const contentVersionService = getService('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(); + }); +}; diff --git a/server/src/middlewares/index.ts b/server/src/middlewares/index.ts new file mode 100644 index 0000000..a5f8d54 --- /dev/null +++ b/server/src/middlewares/index.ts @@ -0,0 +1,4 @@ +export default {}; + +export * from './publish-with-version'; +export * from './delete-with-versions'; diff --git a/server/src/middlewares/publish-with-version.ts b/server/src/middlewares/publish-with-version.ts new file mode 100644 index 0000000..d6fcdb3 --- /dev/null +++ b/server/src/middlewares/publish-with-version.ts @@ -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(ctx, 'request.params'); + const modelDef = strapi.getModel(model); + const contentTypeService = getService('contentTypes'); + const contentVersionService = getService('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); + }); +}; diff --git a/server/src/pluginId.ts b/server/src/pluginId.ts new file mode 100644 index 0000000..c91b211 --- /dev/null +++ b/server/src/pluginId.ts @@ -0,0 +1 @@ +export const PLUGIN_ID = 'content-versioning'; diff --git a/server/src/policies/index.ts b/server/src/policies/index.ts new file mode 100644 index 0000000..ff8b4c5 --- /dev/null +++ b/server/src/policies/index.ts @@ -0,0 +1 @@ +export default {}; diff --git a/server/src/register.ts b/server/src/register.ts new file mode 100644 index 0000000..2ed37ef --- /dev/null +++ b/server/src/register.ts @@ -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; diff --git a/server/src/routes/index.ts b/server/src/routes/index.ts new file mode 100644 index 0000000..93f986a --- /dev/null +++ b/server/src/routes/index.ts @@ -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', + }, +]; diff --git a/server/src/services/content-types.ts b/server/src/services/content-types.ts new file mode 100644 index 0000000..6e02680 --- /dev/null +++ b/server/src/services/content-types.ts @@ -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, +}; diff --git a/server/src/services/content-version.ts b/server/src/services/content-version.ts new file mode 100644 index 0000000..a418214 --- /dev/null +++ b/server/src/services/content-version.ts @@ -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; + +export type ContentVersionService = ReturnType; + +const contentVersionService = ({ strapi }: { strapi: Core.Strapi }) => ({ + async createVersion(data: CreateVersionInput) { + return await strapi.db.transaction(async () => { + const config = getConfig(); + const contentVersionLimit = config('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; diff --git a/server/src/services/index.ts b/server/src/services/index.ts new file mode 100644 index 0000000..aca96c5 --- /dev/null +++ b/server/src/services/index.ts @@ -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, +}; diff --git a/server/src/types.ts b/server/src/types.ts new file mode 100644 index 0000000..87a3a68 --- /dev/null +++ b/server/src/types.ts @@ -0,0 +1,46 @@ +import { UID } from '@strapi/strapi'; + +export type ContentVersion> = { + 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; +}; + +export type Config = { + contentVersionLimit: number; +}; diff --git a/server/src/utils/getRelationLabel.ts b/server/src/utils/getRelationLabel.ts new file mode 100644 index 0000000..66b83b8 --- /dev/null +++ b/server/src/utils/getRelationLabel.ts @@ -0,0 +1,9 @@ +export const getRelationLabel = (content: Record, mainField?: string): string => { + const label = mainField && content[mainField] ? content[mainField] : null; + + if (typeof label === 'string') { + return label; + } + + return content.documentId as string; +}; diff --git a/server/src/utils/index.ts b/server/src/utils/index.ts new file mode 100644 index 0000000..7c29b1f --- /dev/null +++ b/server/src/utils/index.ts @@ -0,0 +1,16 @@ +import { Core, UID } from '@strapi/strapi'; +import { PLUGIN_ID } from '../pluginId'; + +export * from './prepareVersionFormData'; +export * from './getRelationLabel'; + +export const getService = (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 {}; diff --git a/server/src/utils/isLocalizedContentType.ts b/server/src/utils/isLocalizedContentType.ts new file mode 100644 index 0000000..0717f19 --- /dev/null +++ b/server/src/utils/isLocalizedContentType.ts @@ -0,0 +1,5 @@ +import { UID } from '@strapi/strapi'; + +export const isLocalizedContentType = (model: UID.Schema) => { + return strapi.plugin('i18n')?.service('content-types')?.isLocalizedContentType(model); +}; diff --git a/server/src/utils/prepareVersionFormData.ts b/server/src/utils/prepareVersionFormData.ts new file mode 100644 index 0000000..0f2f009 --- /dev/null +++ b/server/src/utils/prepareVersionFormData.ts @@ -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; + locale: string | null; +}; + +type RelationValue = {id: number; documentId: string} & Record; + +type PrepareRelationProps = { + metadata?: Metadata; + currentData?: RelationValue | RelationValue[]; + versionData?: RelationValue | RelationValue[]; + locale: string | null; +}; + +const toArray = (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(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) => { + 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(currentData, attributeKey); + const versionDynamicZone = get(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(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); +}; diff --git a/server/src/utils/withTempKeys.ts b/server/src/utils/withTempKeys.ts new file mode 100644 index 0000000..625ca4a --- /dev/null +++ b/server/src/utils/withTempKeys.ts @@ -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] })); +}; diff --git a/server/tsconfig.build.json b/server/tsconfig.build.json new file mode 100644 index 0000000..bdee44c --- /dev/null +++ b/server/tsconfig.build.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig", + "include": ["./src"], + "exclude": ["**/*.test.ts"], + "compilerOptions": { + "rootDir": "../", + "baseUrl": ".", + "outDir": "./dist" + } +} diff --git a/server/tsconfig.json b/server/tsconfig.json new file mode 100644 index 0000000..9cfefe4 --- /dev/null +++ b/server/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "@strapi/typescript-utils/tsconfigs/server", + "include": ["./src"], + "compilerOptions": { + "rootDir": "../", + "baseUrl": "." + } +}