diff --git a/packages/strapi-plugin-versioning/src/extensions/content-manager/admin/src/components/FieldComponent/index.js b/packages/strapi-plugin-versioning/src/extensions/content-manager/admin/src/components/FieldComponent/index.js index 6ce3383..97ce8c5 100644 --- a/packages/strapi-plugin-versioning/src/extensions/content-manager/admin/src/components/FieldComponent/index.js +++ b/packages/strapi-plugin-versioning/src/extensions/content-manager/admin/src/components/FieldComponent/index.js @@ -1,22 +1,21 @@ /* eslint-disable import/no-cycle */ -import React, { memo } from "react"; -import PropTypes from "prop-types"; -import { get, size } from "lodash"; -import { FormattedMessage } from "react-intl"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import isEqual from "react-fast-compare"; -import pluginId from "../../pluginId"; -import useEditView from "../../hooks/useEditView"; -import ComponentInitializer from "../ComponentInitializer"; -import NonRepeatableComponent from "../NonRepeatableComponent"; -import NotAllowedInput from "../NotAllowedInput"; -import RepeatableComponent from "../RepeatableComponent"; -import connect from "./utils/connect"; -import select from "./utils/select"; -import ComponentIcon from "./ComponentIcon"; -import Label from "./Label"; -import Reset from "./ResetComponent"; -import Wrapper from "./Wrapper"; +import React, { memo } from 'react'; +import PropTypes from 'prop-types'; +import { size } from 'lodash'; +import { FormattedMessage, useIntl } from 'react-intl'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import isEqual from 'react-fast-compare'; +import { NotAllowedInput, LabelIconWrapper } from 'strapi-helper-plugin'; +import pluginId from '../../pluginId'; +import ComponentInitializer from '../ComponentInitializer'; +import NonRepeatableComponent from '../NonRepeatableComponent'; +import RepeatableComponent from '../RepeatableComponent'; +import connect from './utils/connect'; +import select from './utils/select'; +import ComponentIcon from './ComponentIcon'; +import Label from './Label'; +import Reset from './ResetComponent'; +import Wrapper from './Wrapper'; const FieldComponent = ({ componentFriendlyName, @@ -27,6 +26,7 @@ const FieldComponent = ({ isRepeatable, isNested, label, + labelIcon, max, min, name, @@ -39,27 +39,23 @@ const FieldComponent = ({ dataForCurrentVersion, isVersionCurrent, }) => { - const { allLayoutData } = useEditView(); - + const { formatMessage } = useIntl(); const componentValueLength = size(componentValue); - const isInitialized = componentValue || isFromDynamicZone; + const isInitialized = componentValue !== null || isFromDynamicZone; const showResetComponent = !isRepeatable && isInitialized && !isFromDynamicZone && hasChildrenAllowedFields; - const currentComponentSchema = get( - allLayoutData, - ["components", componentUid], - {}, - ); - const displayedFields = get(currentComponentSchema, ["layouts", "edit"], []); + const formattedLabelIcon = labelIcon + ? { icon: labelIcon.icon, title: formatMessage(labelIcon.title) } + : null; if (!hasChildrenAllowedFields && isCreatingEntry) { return (
- +
); } @@ -71,7 +67,7 @@ const FieldComponent = ({ ) { return (
- +
); } @@ -89,8 +85,15 @@ const FieldComponent = ({ )} {showResetComponent && ( @@ -128,14 +129,11 @@ const FieldComponent = ({ componentValue={componentValue} componentValueLength={componentValueLength} componentUid={componentUid} - fields={displayedFields} - isFromDynamicZone={isFromDynamicZone} isNested={isNested} isReadOnly={isReadOnly} max={max} min={min} name={name} - schema={currentComponentSchema} dataForCurrentVersion={dataForCurrentVersion} isVersionCurrent={isVersionCurrent} /> @@ -149,11 +147,12 @@ FieldComponent.defaultProps = { componentFriendlyName: null, hasChildrenAllowedFields: false, hasChildrenReadableFields: false, - icon: "smile", + icon: 'smile', isFromDynamicZone: false, isReadOnly: false, isRepeatable: false, isNested: false, + labelIcon: null, max: Infinity, min: -Infinity, dataForCurrentVersion: undefined, @@ -173,6 +172,13 @@ FieldComponent.propTypes = { isRepeatable: PropTypes.bool, isNested: PropTypes.bool, label: PropTypes.string.isRequired, + labelIcon: PropTypes.shape({ + icon: PropTypes.node.isRequired, + title: PropTypes.shape({ + id: PropTypes.string.isRequired, + defaultMessage: PropTypes.string.isRequired, + }), + }), max: PropTypes.number, min: PropTypes.number, name: PropTypes.string.isRequired, diff --git a/packages/strapi-plugin-versioning/src/extensions/content-manager/admin/src/components/Inputs/index.js b/packages/strapi-plugin-versioning/src/extensions/content-manager/admin/src/components/Inputs/index.js index fe088d3..01ae643 100644 --- a/packages/strapi-plugin-versioning/src/extensions/content-manager/admin/src/components/Inputs/index.js +++ b/packages/strapi-plugin-versioning/src/extensions/content-manager/admin/src/components/Inputs/index.js @@ -1,134 +1,208 @@ -import React, { memo, useMemo, useEffect } from "react"; -import PropTypes from "prop-types"; -import { get, isEmpty, omit, toLower } from "lodash"; -import { FormattedMessage } from "react-intl"; -import { Inputs as InputsIndex } from "@buffetjs/custom"; -import { useStrapi } from "strapi-helper-plugin"; - -import useDataManager from "../../hooks/useDataManager"; -import InputJSONWithErrors from "../InputJSONWithErrors"; -import SelectWrapper from "../SelectWrapper"; -import WysiwygWithErrors from "../WysiwygWithErrors"; -import InputUID from "../InputUID"; - -const getInputType = (type = "") => { - switch (toLower(type)) { - case "boolean": - return "bool"; - case "biginteger": - return "text"; - case "decimal": - case "float": - case "integer": - return "number"; - case "date": - case "datetime": - case "time": - return type; - case "email": - return "email"; - case "enumeration": - return "select"; - case "password": - return "password"; - case "string": - return "text"; - case "text": - return "textarea"; - case "media": - case "file": - case "files": - return "media"; - case "json": - return "json"; - case "wysiwyg": - case "WYSIWYG": - case "richtext": - return "wysiwyg"; - case "uid": - return "uid"; - default: - return type || "text"; - } -}; +import React, { memo, useEffect, useMemo } from 'react'; +import PropTypes from 'prop-types'; +import { get, omit, take } from 'lodash'; +import isEqual from 'react-fast-compare'; +import { useIntl } from 'react-intl'; +import { Inputs as InputsIndex } from '@buffetjs/custom'; +import { NotAllowedInput, useStrapi } from 'strapi-helper-plugin'; +import { useContentTypeLayout } from '../../hooks'; +import { getFieldName } from '../../utils'; +import InputJSONWithErrors from '../InputJSONWithErrors'; +import SelectWrapper from '../SelectWrapper'; +import WysiwygWithErrors from '../WysiwygWithErrors'; +import InputUID from '../InputUID'; +import { + connect, + generateOptions, + getInputType, + getStep, + select, + VALIDATIONS_TO_OMIT, +} from './utils'; function Inputs({ + allowedFields, autoFocus, + fieldSchema, + formErrors, + isCreatingEntry, keys, - layout, - name, + labelIcon, + metadatas, onBlur, + onChange, + readableFields, + shouldNotRunValidations, + queryInfos, + value, dataForCurrentVersion, isVersionCurrent, }) { const { strapi: { fieldApi }, } = useStrapi(); + const { contentType: currentContentTypeLayout } = useContentTypeLayout(); + const { formatMessage } = useIntl(); - const { - didCheckErrors, - formErrors, - modifiedData, - onChange, - initialData, - } = useDataManager(); - const attribute = useMemo( - () => get(layout, ["schema", "attributes", name], {}), - [layout, name], - ); - const metadatas = useMemo( - () => get(layout, ["metadatas", name, "edit"], {}), - [layout, name], - ); - const disabled = useMemo(() => !get(metadatas, "editable", true), [ + const labelIconformatted = labelIcon + ? { icon: labelIcon.icon, title: formatMessage(labelIcon.title) } + : labelIcon; + + const disabled = useMemo(() => !get(metadatas, 'editable', true), [ metadatas, ]); - const type = useMemo(() => get(attribute, "type", null), [attribute]); - const regexpString = useMemo(() => get(attribute, "regex", null), [ - attribute, + const type = fieldSchema.type; + + const errorId = useMemo(() => { + return get(formErrors, [keys, 'id'], null); + }, [formErrors, keys]); + + const errorMessage = errorId + ? formatMessage({ id: errorId, defaultMessage: errorId }) + : null; + + const fieldName = useMemo(() => { + return getFieldName(keys); + }, [keys]); + + const validations = useMemo(() => { + const inputValidations = omit( + fieldSchema, + shouldNotRunValidations + ? [...VALIDATIONS_TO_OMIT, 'required', 'minLength'] + : VALIDATIONS_TO_OMIT, + ); + + const regexpString = fieldSchema.regex || null; + + if (regexpString) { + const regexp = new RegExp(regexpString); + + if (regexp) { + inputValidations.regex = regexp; + } + } + + return inputValidations; + }, [fieldSchema, shouldNotRunValidations]); + + const isRequired = useMemo(() => get(validations, ['required'], false), [ + validations, ]); - const value = !isVersionCurrent - ? get(dataForCurrentVersion, keys, null) - : get(modifiedData, keys, null); - const initialValue = get(initialData, keys, null); - const temporaryErrorIdUntilBuffetjsSupportsFormattedMessage = - "app.utils.defaultMessage"; - const errorId = get( - formErrors, - [keys, "id"], - temporaryErrorIdUntilBuffetjsSupportsFormattedMessage, - ); - let validationsToOmit = [ - "type", - "model", - "via", - "collection", - "default", - "plugin", - "enum", - "regex", - ]; + const isChildOfDynamicZone = useMemo(() => { + const attributes = get(currentContentTypeLayout, ['attributes'], {}); + const foundAttributeType = get(attributes, [fieldName[0], 'type'], null); + + return foundAttributeType === 'dynamiczone'; + }, [currentContentTypeLayout, fieldName]); + + const inputType = useMemo(() => { + return getInputType(type); + }, [type]); + const getInputValue = () => { + if (!isVersionCurrent) { + return get(dataForCurrentVersion, keys); + } + // Fix for input file multipe + if (type === 'media' && !value) { + return []; + } + + return value; + }; + const inputValue = getInputValue(); useEffect(() => { if (isVersionCurrent) { onChange({ - target: { name: keys, value: initialValue, type: getInputType(type) }, + target: { name: keys, value: inputValue, type: getInputType(type) }, }); } else { onChange({ target: { name: keys, value, type: getInputType(type) } }); } }, [isVersionCurrent]); - const validations = omit(attribute, validationsToOmit); + const step = useMemo(() => { + return getStep(type); + }, [type]); - if (regexpString) { - const regexp = new RegExp(regexpString); + const isUserAllowedToEditField = useMemo(() => { + const joinedName = fieldName.join('.'); - if (regexp) { - validations.regex = regexp; + if (allowedFields.includes(joinedName)) { + return true; } - } + + if (isChildOfDynamicZone) { + return allowedFields.includes(fieldName[0]); + } + + const isChildOfComponent = fieldName.length > 1; + + if (isChildOfComponent) { + const parentFieldName = take(fieldName, fieldName.length - 1).join('.'); + + return allowedFields.includes(parentFieldName); + } + + return false; + }, [allowedFields, fieldName, isChildOfDynamicZone]); + + const isUserAllowedToReadField = useMemo(() => { + const joinedName = fieldName.join('.'); + + if (readableFields.includes(joinedName)) { + return true; + } + + if (isChildOfDynamicZone) { + return readableFields.includes(fieldName[0]); + } + + const isChildOfComponent = fieldName.length > 1; + + if (isChildOfComponent) { + const parentFieldName = take(fieldName, fieldName.length - 1).join('.'); + + return readableFields.includes(parentFieldName); + } + + return false; + }, [readableFields, fieldName, isChildOfDynamicZone]); + + const shouldDisplayNotAllowedInput = useMemo(() => { + return isUserAllowedToReadField || isUserAllowedToEditField; + }, [isUserAllowedToEditField, isUserAllowedToReadField]); + + const shouldDisableField = useMemo(() => { + if (!isCreatingEntry) { + const doesNotHaveRight = + isUserAllowedToReadField && !isUserAllowedToEditField; + + if (doesNotHaveRight) { + return true; + } + + return disabled; + } + + return disabled; + }, [ + disabled, + isCreatingEntry, + isUserAllowedToEditField, + isUserAllowedToReadField, + ]); + + const options = useMemo( + () => generateOptions(fieldSchema.enum || [], isRequired), + [fieldSchema, isRequired], + ); + + const otherFields = useMemo(() => { + return fieldApi.getFields(); + }, [fieldApi]); const { description, visible } = metadatas; @@ -136,121 +210,105 @@ function Inputs({ return null; } - const isRequired = get(validations, ["required"], false); + if (!shouldDisplayNotAllowedInput) { + return ( + + ); + } - if (type === "relation") { + if (type === 'relation') { return (
); } - let inputValue = value; - - // Fix for input file multipe - if (type === "media" && !value) { - inputValue = []; - } - - let step; - - if (type === "float" || type === "decimal") { - step = "any"; - } else if (type === "time" || type === "datetime") { - step = 30; - } else { - step = "1"; - } - - const options = get(attribute, "enum", []).map((v) => { - return ( - - ); - }); - - const enumOptions = [ - - {(msg) => ( - - )} - , - ...options, - ]; - return ( - - {(error) => { - return ( - - ); + + multiple={fieldSchema.multiple || false} + attribute={fieldSchema} + name={keys} + onBlur={onBlur} + onChange={onChange} + options={options} + step={step} + type={inputType} + validations={validations} + value={inputValue} + withDefaultValue={false} + /> ); } Inputs.defaultProps = { autoFocus: false, + formErrors: {}, + labelIcon: null, onBlur: null, + queryInfos: {}, + value: null, dataForCurrentVersion: null, isVersionCurrent: true, }; Inputs.propTypes = { + allowedFields: PropTypes.array.isRequired, autoFocus: PropTypes.bool, + fieldSchema: PropTypes.object.isRequired, + formErrors: PropTypes.object, keys: PropTypes.string.isRequired, - layout: PropTypes.object.isRequired, - name: PropTypes.string.isRequired, + isCreatingEntry: PropTypes.bool.isRequired, + labelIcon: PropTypes.shape({ + icon: PropTypes.node.isRequired, + title: PropTypes.shape({ + id: PropTypes.string.isRequired, + defaultMessage: PropTypes.string.isRequired, + }).isRequired, + }), + metadatas: PropTypes.object.isRequired, onBlur: PropTypes.func, onChange: PropTypes.func.isRequired, + readableFields: PropTypes.array.isRequired, + shouldNotRunValidations: PropTypes.bool.isRequired, + queryInfos: PropTypes.shape({ + containsKey: PropTypes.string, + defaultParams: PropTypes.object, + endPoint: PropTypes.string, + }), + value: PropTypes.any, dataForCurrentVersion: PropTypes.object, isVersionCurrent: PropTypes.bool, }; -export default memo(Inputs); +const Memoized = memo(Inputs, isEqual); + +export default connect(Memoized, select); diff --git a/packages/strapi-plugin-versioning/src/extensions/content-manager/admin/src/components/NonRepeatableComponent/index.js b/packages/strapi-plugin-versioning/src/extensions/content-manager/admin/src/components/NonRepeatableComponent/index.js index d132276..35a2baa 100644 --- a/packages/strapi-plugin-versioning/src/extensions/content-manager/admin/src/components/NonRepeatableComponent/index.js +++ b/packages/strapi-plugin-versioning/src/extensions/content-manager/admin/src/components/NonRepeatableComponent/index.js @@ -1,67 +1,74 @@ /* eslint-disable react/no-array-index-key */ /* eslint-disable import/no-cycle */ -import React from "react"; -import PropTypes from "prop-types"; -import { get } from "lodash"; -import NonRepeatableWrapper from "../NonRepeatableWrapper"; -import Inputs from "../Inputs"; -import FieldComponent from "../FieldComponent"; +import React, { useMemo } from 'react'; +import PropTypes from 'prop-types'; +import { useContentTypeLayout } from '../../hooks'; +import NonRepeatableWrapper from '../NonRepeatableWrapper'; +import Inputs from '../Inputs'; +import FieldComponent from '../FieldComponent'; const NonRepeatableComponent = ({ componentUid, - fields, isFromDynamicZone, name, - schema, isVersionCurrent, dataForCurrentVersion, }) => { - const getField = (fieldName) => - get(schema, ["schema", "attributes", fieldName], {}); - const getMeta = (fieldName) => - get(schema, ["metadatas", fieldName, "edit"], {}); + const { getComponentLayout } = useContentTypeLayout(); + const componentLayoutData = useMemo(() => getComponentLayout(componentUid), [ + componentUid, + getComponentLayout, + ]); + const fields = componentLayoutData.layouts.edit; return ( {fields.map((fieldRow, key) => { return (
- {fieldRow.map((field) => { - const currentField = getField(field.name); - const isComponent = get(currentField, "type", "") === "component"; - const keys = `${name}.${field.name}`; + {fieldRow.map( + ({ + name: fieldName, + size, + metadatas, + fieldSchema, + queryInfos, + }) => { + const isComponent = fieldSchema.type === 'component'; + const keys = `${name}.${fieldName}`; - if (isComponent) { - const compoUid = currentField.component; - const metas = getMeta(field.name); + if (isComponent) { + const compoUid = fieldSchema.component; + + return ( + + ); + } return ( - +
+ +
); - } - - return ( -
- -
- ); - })} + }, + )}
); })} @@ -70,20 +77,13 @@ const NonRepeatableComponent = ({ }; NonRepeatableComponent.defaultProps = { - fields: [], isFromDynamicZone: false, - dataForCurrentVersion: undefined, - isVersionCurrent: true, }; NonRepeatableComponent.propTypes = { componentUid: PropTypes.string.isRequired, - fields: PropTypes.array, isFromDynamicZone: PropTypes.bool, name: PropTypes.string.isRequired, - schema: PropTypes.object.isRequired, - dataForCurrentVersion: PropTypes.object, - isVersionCurrent: PropTypes.bool, }; export default NonRepeatableComponent; diff --git a/packages/strapi-plugin-versioning/src/extensions/content-manager/admin/src/components/RepeatableComponent/DraggedItem/index.js b/packages/strapi-plugin-versioning/src/extensions/content-manager/admin/src/components/RepeatableComponent/DraggedItem/index.js index 133e39c..0045a00 100644 --- a/packages/strapi-plugin-versioning/src/extensions/content-manager/admin/src/components/RepeatableComponent/DraggedItem/index.js +++ b/packages/strapi-plugin-versioning/src/extensions/content-manager/admin/src/components/RepeatableComponent/DraggedItem/index.js @@ -1,17 +1,15 @@ /* eslint-disable import/no-cycle */ -import React, { memo, useEffect, useRef, useState } from "react"; -import PropTypes from "prop-types"; -import { get } from "lodash"; -import { Collapse } from "reactstrap"; -import { useDrag, useDrop } from "react-dnd"; -import { getEmptyImage } from "react-dnd-html5-backend"; -import useEditView from "../../../hooks/useEditView"; -import ItemTypes from "../../../utils/ItemTypes"; -import Inputs from "../../Inputs"; -import FieldComponent from "../../FieldComponent"; -import Banner from "../Banner"; -import FormWrapper from "../FormWrapper"; -import { connect, select } from "./utils"; +import React, { memo, useEffect, useRef, useState } from 'react'; +import PropTypes from 'prop-types'; +import { Collapse } from 'reactstrap'; +import { useDrag, useDrop } from 'react-dnd'; +import { getEmptyImage } from 'react-dnd-html5-backend'; +import ItemTypes from '../../../utils/ItemTypes'; +import Inputs from '../../Inputs'; +import FieldComponent from '../../FieldComponent'; +import Banner from '../Banner'; +import FormWrapper from '../FormWrapper'; +import { connect, select } from './utils'; /* eslint-disable react/no-array-index-key */ @@ -21,22 +19,17 @@ import { connect, select } from "./utils"; const DraggedItem = ({ componentFieldName, - componentUid, doesPreviousFieldContainErrorsAndIsOpen, - fields, hasErrors, hasMinError, isFirst, isReadOnly, isOpen, - moveCollapse, onClickToggle, - removeCollapse, schema, toggleCollapses, dataForCurrentVersion, isVersionCurrent, - // Retrieved from the select function moveComponentField, removeRepeatableField, @@ -44,16 +37,17 @@ const DraggedItem = ({ checkFormErrors, displayedValue, }) => { - const { setIsDraggingComponent, unsetIsDraggingComponent } = useEditView(); const dragRef = useRef(null); const dropRef = useRef(null); const [showForm, setShowForm] = useState(false); + const fields = schema.layouts.edit; + useEffect(() => { if (isOpen || !isVersionCurrent) { setShowForm(true); } - }, [isOpen]); + }, [isOpen, isVersionCurrent]); useEffect(() => { if (!isVersionCurrent) { @@ -73,12 +67,12 @@ const DraggedItem = ({ const dragPath = item.originalPath; const hoverPath = componentFieldName; - const fullPathToComponentArray = dragPath.split("."); + const fullPathToComponentArray = dragPath.split('.'); const dragIndexString = fullPathToComponentArray .slice() .splice(-1) - .join(""); - const hoverIndexString = hoverPath.split(".").splice(-1).join(""); + .join(''); + const hoverIndexString = hoverPath.split('.').splice(-1).join(''); const pathToComponentArray = fullPathToComponentArray.slice( 0, fullPathToComponentArray.length - 1, @@ -114,12 +108,7 @@ const DraggedItem = ({ } // Time to actually perform the action in the data moveComponentField(pathToComponentArray, dragIndex, hoverIndex); - // Time to actually perform the action in the synchronized collapses - moveCollapse(dragIndex, hoverIndex); - // Note: we're mutating the monitor item here! - // Generally it's better to avoid mutations, - // but it's good here for the sake of performance - // to avoid expensive index searches. + item.originalPath = hoverPath; }, }); @@ -132,12 +121,8 @@ const DraggedItem = ({ begin: () => { // Close all collapses toggleCollapses(-1); - // Prevent the relations select from firing requests - setIsDraggingComponent(); }, end: () => { - // Enable the relations select to fire requests - unsetIsDraggingComponent(); // Update the errors triggerFormValidation(); }, @@ -150,11 +135,6 @@ const DraggedItem = ({ preview(getEmptyImage(), { captureDraggingState: false }); }, [preview]); - const getField = (fieldName) => - get(schema, ["schema", "attributes", fieldName], {}); - const getMeta = (fieldName) => - get(schema, ["metadatas", fieldName, "edit"], {}); - // Create the refs // We need 1 for the drop target // 1 for the drag target @@ -180,64 +160,63 @@ const DraggedItem = ({ onClickToggle={onClickToggle} onClickRemove={() => { removeRepeatableField(componentFieldName); - removeCollapse(); + toggleCollapses(); }} ref={refs} /> setShowForm(false)} > {!isDragging && ( {showForm && fields.map((fieldRow, key) => { return (
- {fieldRow.map((field) => { - const currentField = getField(field.name); - const isComponent = - get(currentField, "type", "") === "component"; - const keys = `${componentFieldName}.${field.name}`; + {fieldRow.map( + ({ name, fieldSchema, metadatas, queryInfos, size }) => { + const isComponent = fieldSchema.type === 'component'; + const keys = `${componentFieldName}.${name}`; + + if (isComponent) { + const componentUid = fieldSchema.component; - if (isComponent) { - const componentUid = currentField.component; - const metas = getMeta(field.name); + return ( + + ); + } return ( - +
+ +
); - } - - return ( -
- -
- ); - })} + }, + )}
); })} @@ -250,30 +229,22 @@ const DraggedItem = ({ DraggedItem.defaultProps = { doesPreviousFieldContainErrorsAndIsOpen: false, - fields: [], hasErrors: false, hasMinError: false, isFirst: false, isOpen: false, - moveCollapse: () => {}, toggleCollapses: () => {}, - dataForCurrentVersion: undefined, - isVersionCurrent: true, }; DraggedItem.propTypes = { componentFieldName: PropTypes.string.isRequired, - componentUid: PropTypes.string.isRequired, doesPreviousFieldContainErrorsAndIsOpen: PropTypes.bool, - fields: PropTypes.array, hasErrors: PropTypes.bool, hasMinError: PropTypes.bool, isFirst: PropTypes.bool, isOpen: PropTypes.bool, isReadOnly: PropTypes.bool.isRequired, - moveCollapse: PropTypes.func, onClickToggle: PropTypes.func.isRequired, - removeCollapse: PropTypes.func.isRequired, schema: PropTypes.object.isRequired, toggleCollapses: PropTypes.func, moveComponentField: PropTypes.func.isRequired, @@ -281,8 +252,6 @@ DraggedItem.propTypes = { triggerFormValidation: PropTypes.func.isRequired, checkFormErrors: PropTypes.func.isRequired, displayedValue: PropTypes.string.isRequired, - dataForCurrentVersion: PropTypes.object, - isVersionCurrent: PropTypes.bool, }; const Memoized = memo(DraggedItem); diff --git a/packages/strapi-plugin-versioning/src/extensions/content-manager/admin/src/components/RepeatableComponent/index.js b/packages/strapi-plugin-versioning/src/extensions/content-manager/admin/src/components/RepeatableComponent/index.js index 2b2a169..e2fe9be 100644 --- a/packages/strapi-plugin-versioning/src/extensions/content-manager/admin/src/components/RepeatableComponent/index.js +++ b/packages/strapi-plugin-versioning/src/extensions/content-manager/admin/src/components/RepeatableComponent/index.js @@ -1,19 +1,19 @@ +import React, { memo, useCallback, useMemo, useState } from 'react'; /* eslint-disable import/no-cycle */ -import React, { memo, useReducer } from "react"; -import { useDrop } from "react-dnd"; -import PropTypes from "prop-types"; -import { get, take } from "lodash"; -import { FormattedMessage } from "react-intl"; -import { ErrorMessage } from "@buffetjs/styles"; -import pluginId from "../../pluginId"; -import ItemTypes from "../../utils/ItemTypes"; -import connect from "./utils/connect"; -import select from "./utils/select"; -import Button from "./AddFieldButton"; -import DraggedItem from "./DraggedItem"; -import EmptyComponent from "./EmptyComponent"; -import init from "./init"; -import reducer, { initialState } from "./reducer"; +import { useDrop } from 'react-dnd'; +import PropTypes from 'prop-types'; +import { get, take } from 'lodash'; +import { FormattedMessage } from 'react-intl'; +import { ErrorMessage } from '@buffetjs/styles'; +import pluginId from '../../pluginId'; +import { getMaxTempKey } from '../../utils'; +import { useContentTypeLayout } from '../../hooks'; +import ItemTypes from '../../utils/ItemTypes'; +import connect from './utils/connect'; +import select from './utils/select'; +import Button from './AddFieldButton'; +import DraggedItem from './DraggedItem'; +import EmptyComponent from './EmptyComponent'; const RepeatableComponent = ({ addRepeatableComponentToField, @@ -21,50 +21,71 @@ const RepeatableComponent = ({ componentUid, componentValue, componentValueLength, - fields, isNested, isReadOnly, max, min, name, - schema, dataForCurrentVersion, isVersionCurrent, }) => { + const [collapseToOpen, setCollapseToOpen] = useState(''); const [, drop] = useDrop({ accept: ItemTypes.COMPONENT }); + const { getComponentLayout } = useContentTypeLayout(); + const componentLayoutData = useMemo(() => getComponentLayout(componentUid), [ + componentUid, + getComponentLayout, + ]); + + const nextTempKey = useMemo(() => { + return getMaxTempKey(componentValue || []) + 1; + }, [componentValue]); const componentErrorKeys = Object.keys(formErrors) .filter((errorKey) => { - return take(errorKey.split("."), isNested ? 3 : 1).join(".") === name; + return take(errorKey.split('.'), isNested ? 3 : 1).join('.') === name; }) .map((errorKey) => { return errorKey - .split(".") - .slice(0, name.split(".").length + 1) - .join("."); + .split('.') + .slice(0, name.split('.').length + 1) + .join('.'); }); - // We need to synchronize the collapses array with the data - // The key needed for react in the list will be the one from the collapses data - // This way we don't have to mutate the data when it is received and we can use a unique key - const [state, dispatch] = useReducer(reducer, initialState, () => - init(initialState, componentValue), - ); - const { collapses } = state.toJS(); - const toggleCollapses = (index) => { - dispatch({ - type: "TOGGLE_COLLAPSE", - index, - }); + const toggleCollapses = () => { + setCollapseToOpen(''); }; const missingComponentsValue = min - componentValueLength; const errorsArray = componentErrorKeys.map((key) => - get(formErrors, [key, "id"], ""), + get(formErrors, [key, 'id'], ''), ); - const hasMinError = - get(errorsArray, [0], "").includes("min") && - !collapses.some((obj) => obj.isOpen === true); + const hasMinError = get(errorsArray, [0], '').includes('min'); + + const handleClick = useCallback(() => { + if (!isReadOnly) { + if (componentValueLength < max) { + const shouldCheckErrors = hasMinError; + + addRepeatableComponentToField(name, componentUid, shouldCheckErrors); + + setCollapseToOpen(nextTempKey); + } else if (componentValueLength >= max) { + strapi.notification.info( + `${pluginId}.components.notification.info.maximum-requirement`, + ); + } + } + }, [ + addRepeatableComponentToField, + componentUid, + componentValueLength, + hasMinError, + isReadOnly, + max, + name, + nextTempKey, + ]); return (
@@ -78,16 +99,22 @@ const RepeatableComponent = ({
{componentValueLength > 0 && componentValue.map((data, index) => { + const key = data.__temp_key__; + const isOpen = collapseToOpen === key; const componentFieldName = `${name}.${index}`; + const previousComponentTempKey = get(componentValue, [ + index - 1, + '__temp_key__', + ]); const doesPreviousFieldContainErrorsAndIsOpen = componentErrorKeys.includes(`${name}.${index - 1}`) && index !== 0 && - get(collapses, [index - 1, "isOpen"], false) === false; + collapseToOpen === previousComponentTempKey; + const hasErrors = componentErrorKeys.includes(componentFieldName); return ( { - // Close all other collapses and open the selected one - toggleCollapses(index); - }} - removeCollapse={() => { - dispatch({ - type: "REMOVE_COLLAPSE", - index, - }); - }} - moveCollapse={(dragIndex, hoverIndex) => { - dispatch({ - type: "MOVE_COLLAPSE", - dragIndex, - hoverIndex, - }); + if (isOpen) { + setCollapseToOpen(''); + } else { + setCollapseToOpen(key); + } }} parentName={name} - schema={schema} + schema={componentLayoutData} toggleCollapses={toggleCollapses} dataForCurrentVersion={dataForCurrentVersion} isVersionCurrent={isVersionCurrent} @@ -132,29 +149,11 @@ const RepeatableComponent = ({ doesPreviousFieldContainErrorsAndIsClosed={ componentValueLength > 0 && componentErrorKeys.includes(`${name}.${componentValueLength - 1}`) && - collapses[componentValueLength - 1].isOpen === false + componentValue[componentValueLength - 1].__temp_key__ !== + collapseToOpen } type="button" - onClick={() => { - if (!isReadOnly) { - if (componentValueLength < max) { - const shouldCheckErrors = hasMinError; - - addRepeatableComponentToField( - name, - componentUid, - shouldCheckErrors, - ); - dispatch({ - type: "ADD_NEW_FIELD", - }); - } else if (componentValueLength >= max) { - strapi.notification.info( - `${pluginId}.components.notification.info.maximum-requirement`, - ); - } - } - }} + onClick={handleClick} > @@ -163,7 +162,7 @@ const RepeatableComponent = ({ 1 ? ".plural" : ".singular" + missingComponentsValue > 1 ? '.plural' : '.singular' }`} values={{ count: missingComponentsValue }} /> @@ -176,7 +175,6 @@ const RepeatableComponent = ({ RepeatableComponent.defaultProps = { componentValue: null, componentValueLength: 0, - fields: [], formErrors: {}, isNested: false, max: Infinity, @@ -190,14 +188,12 @@ RepeatableComponent.propTypes = { componentUid: PropTypes.string.isRequired, componentValue: PropTypes.oneOfType([PropTypes.array, PropTypes.object]), componentValueLength: PropTypes.number, - fields: PropTypes.array, formErrors: PropTypes.object, isNested: PropTypes.bool, isReadOnly: PropTypes.bool.isRequired, max: PropTypes.number, min: PropTypes.number, name: PropTypes.string.isRequired, - schema: PropTypes.object.isRequired, dataForCurrentVersion: PropTypes.object, isVersionCurrent: PropTypes.bool, }; diff --git a/packages/strapi-plugin-versioning/src/extensions/content-manager/admin/src/components/SelectWrapper/index.js b/packages/strapi-plugin-versioning/src/extensions/content-manager/admin/src/components/SelectWrapper/index.js index 25c697b..00ccd86 100644 --- a/packages/strapi-plugin-versioning/src/extensions/content-manager/admin/src/components/SelectWrapper/index.js +++ b/packages/strapi-plugin-versioning/src/extensions/content-manager/admin/src/components/SelectWrapper/index.js @@ -1,73 +1,88 @@ -/* eslint-disable react-hooks/exhaustive-deps */ -import React, { useState, useEffect, useMemo, useRef, memo } from "react"; -import PropTypes from "prop-types"; -import { FormattedMessage } from "react-intl"; -import { Link, useLocation } from "react-router-dom"; -import { cloneDeep, findIndex, get, isArray, isEmpty, set } from "lodash"; -import { request } from "strapi-helper-plugin"; -import pluginId from "../../pluginId"; -import useDataManager from "../../hooks/useDataManager"; -import useEditView from "../../hooks/useEditView"; -import { getFieldName } from "../../utils"; -import NotAllowedInput from "../NotAllowedInput"; -import SelectOne from "../SelectOne"; -import SelectMany from "../SelectMany"; -import { Nav, Wrapper } from "./components"; -import { connect, select } from "./utils"; +import React, { useCallback, useState, useEffect, useMemo, memo } from 'react'; +import PropTypes from 'prop-types'; +import { FormattedMessage, useIntl } from 'react-intl'; +import { Link, useLocation } from 'react-router-dom'; +import { findIndex, get, isArray, isEmpty, set } from 'lodash'; +import { + DropdownIndicator, + LabelIconWrapper, + NotAllowedInput, + request, + useContentManagerEditViewDataManager, + useQueryParams, +} from 'strapi-helper-plugin'; +import { Flex, Text, Padded } from '@buffetjs/core'; +import { stringify } from 'qs'; +import pluginId from '../../pluginId'; +import SelectOne from '../SelectOne'; +import SelectMany from '../SelectMany'; +import ClearIndicator from './ClearIndicator'; +import IndicatorSeparator from './IndicatorSeparator'; +import Option from './Option'; +import { A, BaselineAlignment } from './components'; +import { connect, select, styles } from './utils'; + +const initialPaginationState = { + _contains: '', + _limit: 20, + _start: 0, +}; + +const buildParams = (query, paramsToKeep) => { + if (!paramsToKeep) { + return {}; + } + + return paramsToKeep.reduce((acc, current) => { + const value = get(query, current, null); + + if (value) { + set(acc, current, value); + } + return acc; + }, {}); +}; function SelectWrapper({ - componentUid, description, editable, label, + labelIcon, isCreatingEntry, isFieldAllowed, isFieldReadable, mainField, name, relationType, - slug, targetModel, placeholder, + queryInfos, valueToSet, }) { + const { formatMessage } = useIntl(); + const [{ query }] = useQueryParams(); // Disable the input in case of a polymorphic relation - const isMorph = relationType.toLowerCase().includes("morph"); + const isMorph = useMemo(() => relationType.toLowerCase().includes('morph'), [relationType]); const { addRelation, modifiedData, moveRelation, onChange, onRemoveRelation, - initialData, - } = useDataManager(); - const { isDraggingComponent } = useEditView(); + initialData + } = useContentManagerEditViewDataManager(); + const { pathname } = useLocation(); + // const value = get(modifiedData, name, null); const value = - valueToSet && valueToSet !== "current" + valueToSet && valueToSet !== 'current' ? valueToSet : get(modifiedData, name, null); const initialValue = get(initialData, name, null); - - // This is needed for making requests when used in a component - const fieldName = useMemo(() => { - const fieldNameArray = getFieldName(name); - return fieldNameArray[fieldNameArray.length - 1]; - }, [name]); - - const { pathname } = useLocation(); - - const [state, setState] = useState({ - _contains: "", - _limit: 20, - _start: 0, - }); + const [state, setState] = useState(initialPaginationState); const [options, setOptions] = useState([]); - const [isLoading, setIsLoading] = useState(true); - const abortController = new AbortController(); - const { signal } = abortController; - const ref = useRef(); - const startRef = useRef(); + const [isLoading, setIsLoading] = useState(false); + const [isOpen, setIsOpen] = useState(false); const filteredOptions = useMemo(() => { return options.filter((option) => { @@ -78,44 +93,86 @@ function SelectWrapper({ } // SelectOne - return get(value, "id", "") !== option.value.id; + return get(value, 'id', '') !== option.value.id; } return true; }); }, [options, value]); - startRef.current = state._start; + const { + endPoint, + containsKey, + defaultParams, + shouldDisplayRelationLink, + paramsToKeep, + } = queryInfos; - ref.current = async () => { - if (isMorph) { - setIsLoading(false); + const isSingle = [ + 'oneWay', + 'oneToOne', + 'manyToOne', + 'oneToManyMorph', + 'oneToOneMorph', + ].includes(relationType); + + const changeRelationValueForCurrentVersion = () => { + if (valueToSet) { + valueToSet !== 'current' + ? onChange({ target: { name, value: valueToSet } }) + : onChange({ target: { name, value: initialValue } }); + } + }; + + useEffect(() => { + changeRelationValueForCurrentVersion(); + }, [valueToSet]); - return; + const idsToOmit = useMemo(() => { + if (!value) { + return []; } - if (!isDraggingComponent) { - try { - const requestUrl = `/${pluginId}/explorer/${slug}/relation-list/${fieldName}`; + if (isSingle) { + return [value.id]; + } - const containsKey = `${mainField}_contains`; - const { _contains, ...restState } = cloneDeep(state); - const params = isEmpty(state._contains) - ? restState - : { [containsKey]: _contains, ...restState }; + return value.map((val) => val.id); + }, [isSingle, value]); - if (componentUid) { - set(params, "_component", componentUid); - } + const getData = useCallback( + async (signal) => { + // Currently polymorphic relations are not handled + if (isMorph) { + setIsLoading(false); + + return; + } + + if (!isFieldAllowed) { + setIsLoading(false); + + return; + } + + setIsLoading(true); + + const params = { _limit: state._limit, ...defaultParams }; + + if (state._contains) { + params[containsKey] = state._contains; + } - const data = await request(requestUrl, { - method: "GET", + try { + const data = await request(endPoint, { + method: 'POST', params, signal, + body: { idsToOmit }, }); const formattedData = data.map((obj) => { - return { value: obj, label: obj[mainField] }; + return { value: obj, label: obj[mainField.name] }; }); setOptions((prevState) => @@ -128,51 +185,40 @@ function SelectWrapper({ return true; } - return ( - prevState.findIndex((el) => el.value.id === obj.value.id) === - index - ); - }), + return prevState.findIndex(el => el.value.id === obj.value.id) === index; + }) ); setIsLoading(false); } catch (err) { - if (err.code !== 20) { - strapi.notification.error("notification.error"); - } + // Silent } - } - }; + }, + [ + isMorph, + isFieldAllowed, + state._limit, + state._contains, + defaultParams, + containsKey, + endPoint, + idsToOmit, + mainField.name, + ], + ); useEffect(() => { - if (state._contains !== "") { - let timer = setTimeout(() => { - ref.current(); - }, 300); - - return () => clearTimeout(timer); - } + const abortController = new AbortController(); + const { signal } = abortController; - if (isFieldAllowed) { - ref.current(); + if (isOpen) { + getData(signal); } - return () => { - abortController.abort(); - }; - }, [state._contains, isFieldAllowed]); + return () => abortController.abort(); + }, [getData, isOpen]); - useEffect(() => { - if (state._start !== 0) { - ref.current(); - } - - return () => { - abortController.abort(); - }; - }, [state._start]); - - const onInputChange = (inputValue, { action }) => { - if (action === "input-change") { + const handleInputChange = (inputValue, { action }) => { + if (action === 'input-change') { setState((prevState) => { if (prevState._contains === inputValue) { return prevState; @@ -185,156 +231,187 @@ function SelectWrapper({ return inputValue; }; - const onMenuScrollToBottom = () => { - setState((prevState) => ({ ...prevState, _start: prevState._start + 20 })); + const handleMenuScrollToBottom = () => { + setState((prevState) => ({ ...prevState, _limit: prevState._limit + 20 })); }; - const isSingle = [ - "oneWay", - "oneToOne", - "manyToOne", - "oneToManyMorph", - "oneToOneMorph", - ].includes(relationType); + const handleMenuClose = () => { + setState(initialPaginationState); + setIsOpen(false); + }; - const changeRelationValueForCurrentVersion = () => { - if (valueToSet && startRef.current != 0) { - valueToSet !== "current" - ? onChange({ target: { name, value: valueToSet } }) - : onChange({ target: { name, value: initialValue } }); + const handleChange = (value) => { + onChange({ target: { name, value: value ? value.value : value } }); + }; + + const handleAddRelation = (value) => { + if (!isEmpty(value)) { + addRelation({ target: { name, value } }); } }; - useEffect(() => { - changeRelationValueForCurrentVersion(); - }, [valueToSet]); + const handleMenuOpen = () => { + setIsOpen(true); + }; - const to = `/plugins/${pluginId}/collectionType/${targetModel}/${ - value ? value.id : null - }`; - const link = - value === null || - value === undefined || - [ - "plugins::users-permissions.role", - "plugins::users-permissions.permission", - ].includes(targetModel) ? null : ( - - + const to = `/plugins/${pluginId}/collectionType/${targetModel}/${value ? value.id : null}`; + + const searchToPersist = stringify(buildParams(query, paramsToKeep), { encode: false }); + + const link = useMemo(() => { + if (!value) { + return null; + } + + if (!shouldDisplayRelationLink) { + return null; + } + + return ( + + + {(msg) => {msg}} + ); + }, [shouldDisplayRelationLink, pathname, to, value, searchToPersist]); + const Component = isSingle ? SelectOne : SelectMany; const associationsLength = isArray(value) ? value.length : 0; - const customStyles = { - option: (provided) => { - return { - ...provided, - maxWidth: "100% !important", - overflow: "hidden", - textOverflow: "ellipsis", - whiteSpace: "nowrap", - }; - }, - }; - const isDisabled = useMemo(() => { if (isMorph) { return true; } if (!isCreatingEntry) { - return !isFieldAllowed && isFieldReadable; + return (!isFieldAllowed && isFieldReadable) || !editable; } return !editable; - }); + }, [isMorph, isCreatingEntry, editable, isFieldAllowed, isFieldReadable]); + + const labelIconformatted = labelIcon + ? { icon: labelIcon.icon, title: formatMessage(labelIcon.title) } + : labelIcon; if (!isFieldAllowed && isCreatingEntry) { - return ; + return ; } if (!isCreatingEntry && !isFieldAllowed && !isFieldReadable) { - return ; + return ; } return ( - - - { - addRelation({ target: { name, value } }); - }} - id={name} - isDisabled={isDisabled} - isLoading={isLoading} - isClearable - mainField={mainField} - move={moveRelation} - name={name} - options={filteredOptions} - onChange={(value) => { - onChange({ target: { name, value: value ? value.value : value } }); - }} - onInputChange={onInputChange} - onMenuClose={() => { - setState((prevState) => ({ ...prevState, _contains: "" })); - }} - onMenuScrollToBottom={onMenuScrollToBottom} - onRemove={onRemoveRelation} - placeholder={ - isEmpty(placeholder) ? ( - - ) : ( - placeholder - ) - } - styles={customStyles} - targetModel={targetModel} - value={value} - /> -
- + + + + + + + {label} + {!isSingle && ` (${associationsLength})`} + + + {labelIconformatted && ( +
+ + {labelIconformatted.icon} + +
+ )} +
+ {isSingle && link} +
+ {!isEmpty(description) && ( + + + + {description} + + + )} + + + + + ) : ( + placeholder + ) + } + searchToPersist={searchToPersist} + styles={styles} + targetModel={targetModel} + value={value} + /> + +
+ ); } SelectWrapper.defaultProps = { - componentUid: null, editable: true, - description: "", - label: "", + description: '', + label: '', + labelIcon: null, isFieldAllowed: true, - placeholder: "", + placeholder: '', valueToSet: null, }; SelectWrapper.propTypes = { - componentUid: PropTypes.string, editable: PropTypes.bool, description: PropTypes.string, label: PropTypes.string, + labelIcon: PropTypes.shape({ + icon: PropTypes.node.isRequired, + title: PropTypes.shape({ + id: PropTypes.string.isRequired, + defaultMessage: PropTypes.string, + }), + }), isCreatingEntry: PropTypes.bool.isRequired, isFieldAllowed: PropTypes.bool, isFieldReadable: PropTypes.bool.isRequired, - mainField: PropTypes.string.isRequired, + mainField: PropTypes.shape({ + name: PropTypes.string.isRequired, + schema: PropTypes.shape({ + type: PropTypes.string.isRequired, + }).isRequired, + }).isRequired, name: PropTypes.string.isRequired, placeholder: PropTypes.string, relationType: PropTypes.string.isRequired, - slug: PropTypes.string.isRequired, targetModel: PropTypes.string.isRequired, + queryInfos: PropTypes.shape({ + containsKey: PropTypes.string.isRequired, + defaultParams: PropTypes.object, + endPoint: PropTypes.string.isRequired, + shouldDisplayRelationLink: PropTypes.bool.isRequired, + paramsToKeep: PropTypes.array, + }).isRequired, valueToSet: PropTypes.oneOfType([PropTypes.object, PropTypes.array]), }; diff --git a/packages/strapi-plugin-versioning/src/extensions/content-manager/admin/src/containers/EditView/index.js b/packages/strapi-plugin-versioning/src/extensions/content-manager/admin/src/containers/EditView/index.js index 143edcd..66dba85 100644 --- a/packages/strapi-plugin-versioning/src/extensions/content-manager/admin/src/containers/EditView/index.js +++ b/packages/strapi-plugin-versioning/src/extensions/content-manager/admin/src/containers/EditView/index.js @@ -1,69 +1,57 @@ -import React, { - memo, - useCallback, - useMemo, - useEffect, - useReducer, - useRef, - useState, -} from "react"; -import PropTypes from "prop-types"; -import { get, isEqual, sortBy } from "lodash"; -import { FormattedMessage } from "react-intl"; -import { Select, Button } from "@buffetjs/core"; -import { useHistory, useLocation, useRouteMatch } from "react-router-dom"; +import React, { memo, useCallback, useEffect, useMemo, useState } from 'react'; +import PropTypes from 'prop-types'; +import { get, isEqual, sortBy } from 'lodash'; +import { FormattedMessage } from 'react-intl'; import { BackHeader, - LiLink, + BaselineAlignment, CheckPermissions, - useUserPermissions, + LiLink, request, -} from "strapi-helper-plugin"; -import pluginId from "../../pluginId"; -import pluginPermissions from "../../permissions"; -import { generatePermissionsObject } from "../../utils"; -import Container from "../../components/Container"; -import DynamicZone from "../../components/DynamicZone"; -import FormWrapper from "../../components/FormWrapper"; -import FieldComponent from "../../components/FieldComponent"; -import Inputs from "../../components/Inputs"; -import SelectWrapper from "../../components/SelectWrapper"; -import getInjectedComponents from "../../utils/getComponents"; -import EditViewDataManagerProvider from "../EditViewDataManagerProvider"; -import EditViewProvider from "../EditViewProvider"; -import Header from "./Header"; -import createAttributesLayout from "./utils/createAttributesLayout"; -import { LinkWrapper, SubWrapper } from "./components"; -import init from "./init"; -import reducer, { initialState } from "./reducer"; -import getRequestUrl from "../../utils/getRequestUrl"; + useGlobalContext, +} from 'strapi-helper-plugin'; +import { Button, Padded, Select } from '@buffetjs/core'; +import pluginId from '../../pluginId'; +import pluginPermissions from '../../permissions'; +import Container from '../../components/Container'; +import DynamicZone from '../../components/DynamicZone'; +import FormWrapper from '../../components/FormWrapper'; +import FieldComponent from '../../components/FieldComponent'; +import Inputs from '../../components/Inputs'; +import SelectWrapper from '../../components/SelectWrapper'; +import { getInjectedComponents } from '../../utils'; +import CollectionTypeFormWrapper from '../CollectionTypeFormWrapper'; +import EditViewDataManagerProvider from '../EditViewDataManagerProvider'; +import SingleTypeFormWrapper from '../SingleTypeFormWrapper'; +import Header from './Header'; +import { + createAttributesLayout, + getFieldsActionMatchingPermissions, +} from './utils'; +import { LinkWrapper, SubWrapper } from './components'; +import DeleteLink from './DeleteLink'; +import InformationCard from './InformationCard'; +import getRequestUrl from '../../utils/getRequestUrl'; +import { useLocation } from 'react-router-dom'; /* eslint-disable react/no-array-index-key */ - const EditView = ({ - components, - currentEnvironment, - deleteLayout, - layouts, - plugins, + allowedActions, + isSingleType, + goBack, + layout, slug, + state, + id, + origin, + userPermissions, }) => { - const formatLayoutRef = useRef(); - formatLayoutRef.current = createAttributesLayout; - const { goBack } = useHistory(); - // Retrieve the search and the pathname + const { currentEnvironment, plugins } = useGlobalContext(); const { pathname } = useLocation(); - const { - params: { contentType }, - } = useRouteMatch("/plugins/content-manager/:contentType"); - const viewPermissions = useMemo(() => generatePermissionsObject(slug), [ - slug, - ]); - const { allowedActions } = useUserPermissions(viewPermissions); - const entityId = pathname.split("/").pop(); - const [versions, setVersions] = useState([{ date: "current" }]); - const [selectedVersion, setSelectedVersion] = useState("current"); + const entityId = pathname.split('/').pop(); + const [versions, setVersions] = useState([{ date: 'current' }]); + const [selectedVersion, setSelectedVersion] = useState('current'); const changeLatestDateToCurrent = (versions) => { if (versions.length) { @@ -71,145 +59,115 @@ const EditView = ({ (previous, next) => new Date(previous.date).getTime() - new Date(next.date).getTime(), ); - return sortedVersions.map((sortedVersion, i, arr) => { if (i === arr.length - 1) { sortedVersion = { ...sortedVersion, - date: "current", + date: 'current', }; } return sortedVersion; }); } - return [{ date: "current" }]; + return [{ date: 'current' }]; }; - useEffect(() => { const getVersions = async () => { try { const versions = await request( getRequestUrl(`explorer/versions/${slug}/${entityId}`), { - method: "GET", + method: 'GET', }, ); + console.log('versions', versions); setVersions(changeLatestDateToCurrent(versions)); } catch (err) { - strapi.notification.error("content-manager.error.relation.fetch"); + strapi.notification.error('content-manager.error.relation.fetch'); } }; - if (entityId != "create") { + if (entityId != 'create') { getVersions(); } }, [slug, entityId]); - const generateDataForSelectedOption = () => versions.length <= 1 ? {} : versions.find((version) => version.date === selectedVersion).content; - const isSingleType = useMemo(() => contentType === "singleType", [ - contentType, - ]); - const [ - { formattedContentTypeLayout, isDraggingComponent }, - dispatch, - ] = useReducer(reducer, initialState, () => init(initialState)); - const allLayoutData = useMemo(() => get(layouts, [slug], {}), [ - layouts, - slug, - ]); + // Here in case of a 403 response when fetching data we will either redirect to the previous page + // Or to the homepage if there's no state in the history stack + const from = get(state, 'from', '/'); + + const { + createActionAllowedFields, + readActionAllowedFields, + updateActionAllowedFields, + } = useMemo(() => { + return getFieldsActionMatchingPermissions(userPermissions, slug); + }, [userPermissions, slug]); + const configurationPermissions = useMemo(() => { + return isSingleType + ? pluginPermissions.singleTypesConfigurations + : pluginPermissions.collectionTypesConfigurations; + }, [isSingleType]); + + const configurationsURL = `/plugins/${pluginId}/${ + isSingleType ? 'singleType' : 'collectionType' + }/${slug}/configurations/edit`; const currentContentTypeLayoutData = useMemo( - () => get(allLayoutData, ["contentType"], {}), - [allLayoutData], - ); - const currentContentTypeLayout = useMemo( - () => get(currentContentTypeLayoutData, ["layouts", "edit"], []), - [currentContentTypeLayoutData], + () => get(layout, ['contentType'], {}), + [layout], ); + const currentContentTypeLayoutRelations = useMemo( - () => get(currentContentTypeLayoutData, ["layouts", "editRelations"], []), - [currentContentTypeLayoutData], - ); - const currentContentTypeSchema = useMemo( - () => get(currentContentTypeLayoutData, ["schema"], {}), + () => get(currentContentTypeLayoutData, ['layouts', 'editRelations'], []), [currentContentTypeLayoutData], ); - const getFieldMetas = useCallback( - (fieldName) => { - return get( - currentContentTypeLayoutData, - ["metadatas", fieldName, "edit"], - {}, - ); - }, - [currentContentTypeLayoutData], - ); - const getField = useCallback( - (fieldName) => { - return get(currentContentTypeSchema, ["attributes", fieldName], {}); - }, - [currentContentTypeSchema], - ); - const getFieldType = useCallback( - (fieldName) => { - return get(getField(fieldName), ["type"], ""); - }, - [getField], - ); - const getFieldComponentUid = useCallback( - (fieldName) => { - return get(getField(fieldName), ["component"], ""); - }, - [getField], + const DataManagementWrapper = useMemo( + () => (isSingleType ? SingleTypeFormWrapper : CollectionTypeFormWrapper), + [isSingleType], ); // Check if a block is a dynamic zone - const isDynamicZone = useCallback( - (block) => { - return block.every((subBlock) => { - return subBlock.every( - (obj) => getFieldType(obj.name) === "dynamiczone", - ); - }); - }, - [getFieldType], - ); - - useEffect(() => { - // Force state to be cleared when navigation from one entry to another - dispatch({ type: "RESET_PROPS" }); - dispatch({ - type: "SET_LAYOUT_DATA", - formattedContentTypeLayout: formatLayoutRef.current( - currentContentTypeLayout, - currentContentTypeSchema.attributes, - ), + const isDynamicZone = useCallback((block) => { + return block.every((subBlock) => { + return subBlock.every((obj) => obj.fieldSchema.type === 'dynamiczone'); }); + }, []); - return () => deleteLayout(slug); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [currentContentTypeLayout, currentContentTypeSchema.attributes]); + const formattedContentTypeLayout = useMemo(() => { + if (!currentContentTypeLayoutData.layouts) { + return []; + } - const isVersionCurrent = () => selectedVersion === "current"; - const currentFieldNames = Object.keys(currentContentTypeSchema.attributes); + return createAttributesLayout( + currentContentTypeLayoutData.layouts.edit, + currentContentTypeLayoutData.attributes + ); + }, [currentContentTypeLayoutData]); + const isVersionCurrent = () => selectedVersion === 'current'; + const currentFieldNames = Object.keys( + currentContentTypeLayoutData.attributes, + ); const currentVersionFieldNames = () => { - if (selectedVersion === "current") { + if (selectedVersion === 'current') { return []; } - return Object.keys(generateDataForSelectedOption()); }; const isSelectVersionContainsAllCurrentRelations = () => { + console.log( + 'generateDataForSelectedOption()', + generateDataForSelectedOption(), + ); if (generateDataForSelectedOption()) { const selectedVersionAttributeNames = Object.keys( generateDataForSelectedOption(), ); - return currentContentTypeLayoutRelations.every((el) => selectedVersionAttributeNames.includes(el), ); @@ -228,10 +186,10 @@ const EditView = ({ const isRevertButtonDisabled = () => { const unnecessaryAttributes = [ - "created_at", - "created_by", - "updated_at", - "updated_by", + 'created_at', + 'created_by', + 'updated_at', + 'updated_by', ]; const sortedCurrentFieldNames = sortBy( removeUnnecessaryAttributes(currentFieldNames, unnecessaryAttributes), @@ -253,246 +211,261 @@ const EditView = ({ !isSelectVersionContainsAllCurrentRelations() ); }; - const findSelectedVersionRelationValue = (name) => { const dataForSelectedOption = generateDataForSelectedOption(); return dataForSelectedOption[name]; }; + // TODO: create a hook to handle/provide the permissions this should be done for the i18n feature return ( - { - dispatch({ - type: "SET_IS_DRAGGING_COMPONENT", - }); - }} - unsetIsDraggingComponent={() => { - dispatch({ - type: "UNSET_IS_DRAGGING_COMPONENT", - }); - }} + - - - -
-
-
- {formattedContentTypeLayout.map((block, blockIndex) => { - if (isDynamicZone(block)) { - const { - 0: { - 0: { name }, - }, - } = block; - const { max, min } = getField(name); - - return ( - - ); - } - - return ( - - {block.map((fieldsBlock, fieldsBlockIndex) => { - return ( -
- {fieldsBlock.map(({ name, size }, fieldIndex) => { - const isComponent = - getFieldType(name) === "component"; - - if (isComponent) { - const componentUid = getFieldComponentUid(name); - const isRepeatable = get( - getField(name), - "repeatable", - false, - ); - const { max, min } = getField(name); - - const label = get( - getFieldMetas(name), - "label", - componentUid, - ); - + {({ + componentsDataStructure, + contentTypeDataStructure, + data, + isCreatingEntry, + isLoadingForData, + onDelete, + onDeleteSucceeded, + onPost, + onPublish, + onPut, + onUnpublish, + redirectionLink, + status, + }) => { + return ( + + {({ onChangeVersion }) => ( + <> + + +
+
+
+ {formattedContentTypeLayout.map((block, blockIndex) => { + if (isDynamicZone(block)) { + const { + 0: { + 0: { name, fieldSchema, metadatas, labelIcon }, + }, + } = block; + const baselineAlignementSize = blockIndex === 0 ? '3px' : '0'; + + return ( + + + + ); + } + + return ( + + {block.map((fieldsBlock, fieldsBlockIndex) => { return ( - +
+ {fieldsBlock.map( + ({ name, size, fieldSchema, labelIcon, metadatas }, fieldIndex) => { + const isComponent = fieldSchema.type === 'component'; + + if (isComponent) { + const { component, max, min, repeatable = false } = fieldSchema; + const componentUid = fieldSchema.component; + + return ( + + ); + } + + return ( +
+ +
+ ); + }, + )} +
); - } - - return ( -
- -
- ); - })} + })} +
+ ); + })} +
+
+ + + {currentContentTypeLayoutData.layouts.editRelations.length > 0 && ( + +
+ {currentContentTypeLayoutData.layouts.editRelations.map( + ({ name, fieldSchema, labelIcon, metadatas, queryInfos }) => { + return ( + + ); + }, + )} +
+
+ )} + +
    + + { + // emitEvent('willEditContentTypeLayoutFromEditView'); + }} + /> + + {getInjectedComponents( + 'editView', + 'right.links', + plugins, + currentEnvironment, + slug, + )} + {allowedActions.canDelete && ( + + )} +
+
+ {entityId !== 'create' && ( +
+
+ +
+
+ { - setSelectedVersion(value); - }} - options={versions.map((el) => el.date).reverse()} - value={selectedVersion} - /> - -
-
- )} -
-
- - - + + + )} + + ); + }} + ); }; EditView.defaultProps = { - currentEnvironment: "production", - emitEvent: () => {}, - plugins: {}, + id: null, + isSingleType: false, + origin: null, + state: {}, }; EditView.propTypes = { - components: PropTypes.array.isRequired, - currentEnvironment: PropTypes.string, - deleteLayout: PropTypes.func.isRequired, - emitEvent: PropTypes.func, - layouts: PropTypes.object.isRequired, - plugins: PropTypes.object, + layout: PropTypes.shape({ + components: PropTypes.object.isRequired, + contentType: PropTypes.shape({ + uid: PropTypes.string.isRequired, + settings: PropTypes.object.isRequired, + metadatas: PropTypes.object.isRequired, + options: PropTypes.object.isRequired, + attributes: PropTypes.object.isRequired, + }).isRequired, + }).isRequired, + id: PropTypes.string, + isSingleType: PropTypes.bool, + goBack: PropTypes.func.isRequired, + origin: PropTypes.string, + state: PropTypes.object, slug: PropTypes.string.isRequired, }; diff --git a/packages/strapi-plugin-versioning/src/extensions/content-manager/admin/src/containers/EditViewDataManagerProvider/index.js b/packages/strapi-plugin-versioning/src/extensions/content-manager/admin/src/containers/EditViewDataManagerProvider/index.js new file mode 100644 index 0000000..864500f --- /dev/null +++ b/packages/strapi-plugin-versioning/src/extensions/content-manager/admin/src/containers/EditViewDataManagerProvider/index.js @@ -0,0 +1,558 @@ +import React, { + useCallback, + useEffect, + useMemo, + useReducer, + useRef, +} from 'react'; +import { cloneDeep, get, isEmpty, isEqual, set } from 'lodash'; +import PropTypes from 'prop-types'; +import { Prompt, Redirect } from 'react-router-dom'; +import { + LoadingIndicatorPage, + useGlobalContext, + OverlayBlocker, + ContentManagerEditViewDataManagerContext, +} from 'strapi-helper-plugin'; +import { getTrad, removeKeyInObject } from '../../utils'; +import reducer, { initialState } from './reducer'; +import { cleanData, createYupSchema, getYupInnerErrors } from './utils'; + +const EditViewDataManagerProvider = ({ + allLayoutData, + allowedActions: { canRead, canUpdate }, + children, + componentsDataStructure, + contentTypeDataStructure, + createActionAllowedFields, + from, + initialValues, + isCreatingEntry, + isLoadingForData, + isSingleType, + onPost, + onPublish, + onPut, + onUnpublish, + readActionAllowedFields, + // Not sure this is needed anymore + redirectToPreviousPage, + slug, + status, + updateActionAllowedFields, +}) => { + const [reducerState, dispatch] = useReducer(reducer, initialState); + const { + formErrors, + initialData, + modifiedData, + modifiedDZName, + shouldCheckErrors, + } = reducerState.toJS(); + + const currentContentTypeLayout = get(allLayoutData, ['contentType'], {}); + + const hasDraftAndPublish = useMemo(() => { + return get(currentContentTypeLayout, ['options', 'draftAndPublish'], false); + }, [currentContentTypeLayout]); + + const shouldNotRunValidations = useMemo(() => { + return hasDraftAndPublish && !initialData.published_at; + }, [hasDraftAndPublish, initialData.published_at]); + + const { emitEvent, formatMessage } = useGlobalContext(); + const emitEventRef = useRef(emitEvent); + + const shouldRedirectToHomepageWhenEditingEntry = useMemo(() => { + if (isLoadingForData) { + return false; + } + + if (isCreatingEntry) { + return false; + } + + if (canRead === false && canUpdate === false) { + return true; + } + + return false; + }, [isLoadingForData, isCreatingEntry, canRead, canUpdate]); + + // TODO check this effect if it is really needed (not prio) + useEffect(() => { + if (!isLoadingForData) { + checkFormErrors(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [shouldCheckErrors]); + + useEffect(() => { + if (shouldRedirectToHomepageWhenEditingEntry) { + strapi.notification.info(getTrad('permissions.not-allowed.update')); + } + }, [shouldRedirectToHomepageWhenEditingEntry]); + + useEffect(() => { + dispatch({ + type: 'SET_DEFAULT_DATA_STRUCTURES', + componentsDataStructure, + contentTypeDataStructure, + }); + }, [componentsDataStructure, contentTypeDataStructure]); + + useEffect(() => { + dispatch({ + type: 'INIT_FORM', + initialValues, + }); + }, [initialValues]); + + const addComponentToDynamicZone = useCallback( + (keys, componentUid, shouldCheckErrors = false) => { + emitEventRef.current('didAddComponentToDynamicZone'); + + dispatch({ + type: 'ADD_COMPONENT_TO_DYNAMIC_ZONE', + keys: keys.split('.'), + componentUid, + shouldCheckErrors, + }); + }, + [], + ); + + const addNonRepeatableComponentToField = useCallback((keys, componentUid) => { + dispatch({ + type: 'ADD_NON_REPEATABLE_COMPONENT_TO_FIELD', + keys: keys.split('.'), + componentUid, + }); + }, []); + + const addRelation = useCallback(({ target: { name, value } }) => { + dispatch({ + type: 'ADD_RELATION', + keys: name.split('.'), + value, + }); + }, []); + + const addRepeatableComponentToField = useCallback( + (keys, componentUid, shouldCheckErrors = false) => { + dispatch({ + type: 'ADD_REPEATABLE_COMPONENT_TO_FIELD', + keys: keys.split('.'), + componentUid, + shouldCheckErrors, + }); + }, + [], + ); + + const yupSchema = useMemo(() => { + const options = { + isCreatingEntry, + isDraft: shouldNotRunValidations, + isFromComponent: false, + }; + + return createYupSchema( + currentContentTypeLayout, + { + components: allLayoutData.components || {}, + }, + options, + ); + }, [ + allLayoutData.components, + currentContentTypeLayout, + isCreatingEntry, + shouldNotRunValidations, + ]); + + const checkFormErrors = useCallback( + async (dataToSet = {}) => { + let errors = {}; + const updatedData = cloneDeep(modifiedData); + + if (!isEmpty(updatedData)) { + set(updatedData, dataToSet.path, dataToSet.value); + } + + try { + // Validate the form using yup + await yupSchema.validate(updatedData, { abortEarly: false }); + } catch (err) { + errors = getYupInnerErrors(err); + + if (modifiedDZName) { + errors = Object.keys(errors).reduce((acc, current) => { + const dzName = current.split('.')[0]; + + if (dzName !== modifiedDZName) { + acc[current] = errors[current]; + } + + return acc; + }, {}); + } + } + + dispatch({ + type: 'SET_FORM_ERRORS', + errors, + }); + }, + [modifiedDZName, modifiedData, yupSchema], + ); + + const handleChange = useCallback( + ({ target: { name, value, type } }, shouldSetInitialValue = false) => { + let inputValue = value; + + // Empty string is not a valid date, + // Set the date to null when it's empty + if (type === 'date' && value === '') { + inputValue = null; + } + + if (type === 'password' && !value) { + dispatch({ + type: 'REMOVE_PASSWORD_FIELD', + keys: name.split('.'), + }); + + return; + } + + // Allow to reset enum + if (type === 'select-one' && value === '') { + inputValue = null; + } + + // Allow to reset number input + if (type === 'number' && value === '') { + inputValue = null; + } + + dispatch({ + type: 'ON_CHANGE', + keys: name.split('.'), + value: inputValue, + shouldSetInitialValue, + }); + }, + [], + ); + + const createFormData = useCallback( + (data) => { + // First we need to remove the added keys needed for the dnd + const preparedData = removeKeyInObject(cloneDeep(data), '__temp_key__'); + // Then we need to apply our helper + const cleanedData = cleanData( + preparedData, + currentContentTypeLayout, + allLayoutData.components, + ); + + return cleanedData; + }, + [allLayoutData.components, currentContentTypeLayout], + ); + + const trackerProperty = useMemo(() => { + if (!hasDraftAndPublish) { + return {}; + } + + return shouldNotRunValidations ? { status: 'draft' } : {}; + }, [hasDraftAndPublish, shouldNotRunValidations]); + + const handleSubmit = useCallback( + async (e) => { + e.preventDefault(); + let errors = {}; + + // First validate the form + try { + await yupSchema.validate(modifiedData, { abortEarly: false }); + + const formData = createFormData(modifiedData); + + if (isCreatingEntry) { + onPost(formData, trackerProperty); + } else { + onPut(formData, trackerProperty); + } + } catch (err) { + console.error('ValidationError'); + console.error(err); + + errors = getYupInnerErrors(err); + } + + dispatch({ + type: 'SET_FORM_ERRORS', + errors, + }); + }, + [ + createFormData, + isCreatingEntry, + modifiedData, + onPost, + onPut, + trackerProperty, + yupSchema, + ], + ); + + const handlePublish = useCallback(async () => { + // Create yup schema here's we need to apply all the validations + const schema = createYupSchema( + currentContentTypeLayout, + { + components: get(allLayoutData, 'components', {}), + }, + { isCreatingEntry, isDraft: false, isFromComponent: false }, + ); + let errors = {}; + + try { + // Validate the form using yup + await schema.validate(modifiedData, { abortEarly: false }); + + onPublish(); + } catch (err) { + console.error('ValidationError'); + console.error(err); + + errors = getYupInnerErrors(err); + } + + dispatch({ + type: 'SET_FORM_ERRORS', + errors, + }); + }, [ + allLayoutData, + currentContentTypeLayout, + isCreatingEntry, + modifiedData, + onPublish, + ]); + + const shouldCheckDZErrors = useCallback( + (dzName) => { + const doesDZHaveError = Object.keys(formErrors).some( + (key) => key.split('.')[0] === dzName, + ); + const shouldCheckErrors = !isEmpty(formErrors) && doesDZHaveError; + + return shouldCheckErrors; + }, + [formErrors], + ); + + const moveComponentDown = useCallback( + (dynamicZoneName, currentIndex) => { + emitEventRef.current('changeComponentsOrder'); + + dispatch({ + type: 'MOVE_COMPONENT_DOWN', + dynamicZoneName, + currentIndex, + shouldCheckErrors: shouldCheckDZErrors(dynamicZoneName), + }); + }, + [shouldCheckDZErrors], + ); + + const moveComponentUp = useCallback( + (dynamicZoneName, currentIndex) => { + emitEventRef.current('changeComponentsOrder'); + + dispatch({ + type: 'MOVE_COMPONENT_UP', + dynamicZoneName, + currentIndex, + shouldCheckErrors: shouldCheckDZErrors(dynamicZoneName), + }); + }, + [shouldCheckDZErrors], + ); + + const moveComponentField = useCallback( + (pathToComponent, dragIndex, hoverIndex) => { + dispatch({ + type: 'MOVE_COMPONENT_FIELD', + pathToComponent, + dragIndex, + hoverIndex, + }); + }, + [], + ); + + const moveRelation = useCallback((dragIndex, overIndex, name) => { + dispatch({ + type: 'MOVE_FIELD', + dragIndex, + overIndex, + keys: name.split('.'), + }); + }, []); + + const onRemoveRelation = useCallback((keys) => { + dispatch({ + type: 'REMOVE_RELATION', + keys, + }); + }, []); + + const removeComponentFromDynamicZone = useCallback( + (dynamicZoneName, index) => { + emitEventRef.current('removeComponentFromDynamicZone'); + + dispatch({ + type: 'REMOVE_COMPONENT_FROM_DYNAMIC_ZONE', + dynamicZoneName, + index, + shouldCheckErrors: shouldCheckDZErrors(dynamicZoneName), + }); + }, + [shouldCheckDZErrors], + ); + + const removeComponentFromField = useCallback((keys, componentUid) => { + dispatch({ + type: 'REMOVE_COMPONENT_FROM_FIELD', + keys: keys.split('.'), + componentUid, + }); + }, []); + + const removeRepeatableField = useCallback((keys, componentUid) => { + dispatch({ + type: 'REMOVE_REPEATABLE_FIELD', + keys: keys.split('.'), + componentUid, + }); + }, []); + + const triggerFormValidation = useCallback(() => { + dispatch({ + type: 'TRIGGER_FORM_VALIDATION', + }); + }, []); + + const onChangeVersion = useCallback((version) => { + dispatch({ + type: 'CHANGE_VERSION', + payload: version, + }); + }, []); + + const overlayBlockerParams = useMemo( + () => ({ + children:
, + noGradient: true, + }), + [] + ); + + // Redirect the user to the previous page if he is not allowed to read/update a document + if (shouldRedirectToHomepageWhenEditingEntry) { + return ; + } + + return ( + + <> + + {isLoadingForData ? ( + + ) : ( + <> + +
{children({ onChangeVersion })}
+ + )} + +
+ ); +}; + +EditViewDataManagerProvider.defaultProps = { + from: '/', + redirectToPreviousPage: () => {}, +}; + +EditViewDataManagerProvider.propTypes = { + allLayoutData: PropTypes.object.isRequired, + allowedActions: PropTypes.object.isRequired, + children: PropTypes.arrayOf(PropTypes.element).isRequired, + componentsDataStructure: PropTypes.object.isRequired, + contentTypeDataStructure: PropTypes.object.isRequired, + createActionAllowedFields: PropTypes.array.isRequired, + from: PropTypes.string, + initialValues: PropTypes.object.isRequired, + isCreatingEntry: PropTypes.bool.isRequired, + isLoadingForData: PropTypes.bool.isRequired, + isSingleType: PropTypes.bool.isRequired, + onPost: PropTypes.func.isRequired, + onPublish: PropTypes.func.isRequired, + onPut: PropTypes.func.isRequired, + onUnpublish: PropTypes.func.isRequired, + readActionAllowedFields: PropTypes.array.isRequired, + redirectToPreviousPage: PropTypes.func, + slug: PropTypes.string.isRequired, + status: PropTypes.string.isRequired, + updateActionAllowedFields: PropTypes.array.isRequired, +}; + +export default EditViewDataManagerProvider; diff --git a/packages/strapi-plugin-versioning/src/extensions/content-manager/admin/src/containers/EditViewDataManagerProvider/reducer.js b/packages/strapi-plugin-versioning/src/extensions/content-manager/admin/src/containers/EditViewDataManagerProvider/reducer.js new file mode 100644 index 0000000..3a13d8d --- /dev/null +++ b/packages/strapi-plugin-versioning/src/extensions/content-manager/admin/src/containers/EditViewDataManagerProvider/reducer.js @@ -0,0 +1,248 @@ +import { fromJS } from 'immutable'; +import { getMaxTempKey } from '../../utils'; + +const initialState = fromJS({ + componentsDataStructure: {}, + contentTypeDataStructure: {}, + formErrors: {}, + initialData: {}, + modifiedData: {}, + shouldCheckErrors: false, + modifiedDZName: null, +}); + +const reducer = (state, action) => { + switch (action.type) { + case 'ADD_NON_REPEATABLE_COMPONENT_TO_FIELD': + return state.updateIn(['modifiedData', ...action.keys], () => { + const defaultDataStructure = state.getIn([ + 'componentsDataStructure', + action.componentUid, + ]); + + return fromJS(defaultDataStructure); + }); + case 'ADD_REPEATABLE_COMPONENT_TO_FIELD': { + return state + .updateIn(['modifiedData', ...action.keys], (list) => { + const defaultDataStructure = state + .getIn(['componentsDataStructure', action.componentUid]) + .set('__temp_key__', getMaxTempKey(list ? list.toJS() : []) + 1); + + if (list) { + return list.push(defaultDataStructure); + } + + return fromJS([defaultDataStructure]); + }) + .update('shouldCheckErrors', (v) => { + if (action.shouldCheckErrors === true) { + return !v; + } + + return v; + }); + } + case 'ADD_COMPONENT_TO_DYNAMIC_ZONE': + return state + .updateIn(['modifiedData', ...action.keys], (list) => { + const defaultDataStructure = state + .getIn(['componentsDataStructure', action.componentUid]) + .set('__component', action.componentUid); + + if (list) { + return list.push(defaultDataStructure); + } + + return fromJS([defaultDataStructure]); + }) + .update('modifiedDZName', () => action.keys[0]) + .update('shouldCheckErrors', (v) => { + if (action.shouldCheckErrors === true) { + return !v; + } + + return v; + }); + case 'ADD_RELATION': + return state.updateIn(['modifiedData', ...action.keys], (list) => { + if (!Array.isArray(action.value) || !action.value.length) { + return list; + } + + const el = action.value[0].value; + + if (list) { + return list.push(fromJS(el)); + } + + return fromJS([el]); + }); + case 'INIT_FORM': { + return state + .update('formErrors', () => fromJS({})) + .update('initialData', () => fromJS(action.initialValues)) + .update('modifiedData', () => fromJS(action.initialValues)) + .update('modifiedDZName', () => null) + .update('shouldCheckErrors', () => false); + } + case 'MOVE_COMPONENT_FIELD': + return state.updateIn( + ['modifiedData', ...action.pathToComponent], + (list) => { + return list + .delete(action.dragIndex) + .insert( + action.hoverIndex, + state.getIn([ + 'modifiedData', + ...action.pathToComponent, + action.dragIndex, + ]), + ); + }, + ); + case 'MOVE_COMPONENT_UP': + return state + .update('shouldCheckErrors', (v) => { + if (action.shouldCheckErrors) { + return !v; + } + + return v; + }) + .updateIn(['modifiedData', action.dynamicZoneName], (list) => { + return list + .delete(action.currentIndex) + .insert( + action.currentIndex - 1, + state.getIn([ + 'modifiedData', + action.dynamicZoneName, + action.currentIndex, + ]), + ); + }); + case 'MOVE_COMPONENT_DOWN': + return state + .update('shouldCheckErrors', (v) => { + if (action.shouldCheckErrors) { + return !v; + } + + return v; + }) + .updateIn(['modifiedData', action.dynamicZoneName], (list) => { + return list + .delete(action.currentIndex) + .insert( + action.currentIndex + 1, + state.getIn([ + 'modifiedData', + action.dynamicZoneName, + action.currentIndex, + ]), + ); + }); + case 'MOVE_FIELD': + return state.updateIn(['modifiedData', ...action.keys], (list) => { + return list + .delete(action.dragIndex) + .insert(action.overIndex, list.get(action.dragIndex)); + }); + case 'ON_CHANGE': { + let newState = state; + const [nonRepeatableComponentKey] = action.keys; + + // This is used to set the initialData for inputs + // that needs an asynchronous initial value like the UID field + // This is just a temporary patch. + // TODO : Refactor the default form creation (workflow) to accept async default values. + if (action.shouldSetInitialValue) { + newState = state.updateIn(['initialData', ...action.keys], () => { + return action.value; + }); + } + + if ( + action.keys.length === 2 && + state.getIn(['modifiedData', nonRepeatableComponentKey]) === null + ) { + newState = newState.updateIn( + ['modifiedData', nonRepeatableComponentKey], + () => fromJS({}), + ); + } + + return newState.updateIn(['modifiedData', ...action.keys], () => { + return action.value; + }); + } + case 'REMOVE_COMPONENT_FROM_DYNAMIC_ZONE': + return state + .update('shouldCheckErrors', (v) => { + if (action.shouldCheckErrors) { + return !v; + } + + return v; + }) + .deleteIn(['modifiedData', action.dynamicZoneName, action.index]); + case 'REMOVE_COMPONENT_FROM_FIELD': { + const componentPathToRemove = ['modifiedData', ...action.keys]; + + return state.updateIn(componentPathToRemove, () => null); + } + case 'REMOVE_PASSWORD_FIELD': { + return state.removeIn(['modifiedData', ...action.keys]); + } + case 'REMOVE_REPEATABLE_FIELD': { + const componentPathToRemove = ['modifiedData', ...action.keys]; + + return state + .update('shouldCheckErrors', (v) => { + const hasErrors = state.get('formErrors').keySeq().size > 0; + + if (hasErrors) { + return !v; + } + + return v; + }) + .deleteIn(componentPathToRemove); + } + case 'REMOVE_RELATION': + return state.removeIn(['modifiedData', ...action.keys.split('.')]); + case 'SET_DEFAULT_DATA_STRUCTURES': + return state + .update('componentsDataStructure', () => + fromJS(action.componentsDataStructure), + ) + .update('contentTypeDataStructure', () => + fromJS(action.contentTypeDataStructure), + ); + case 'SET_FORM_ERRORS': { + return state + .update('modifiedDZName', () => null) + .update('formErrors', () => fromJS(action.errors)); + } + case 'TRIGGER_FORM_VALIDATION': + return state.update('shouldCheckErrors', (v) => { + const hasErrors = state.get('formErrors').keySeq().size > 0; + + if (hasErrors) { + return !v; + } + + return v; + }); + case 'CHANGE_VERSION': { + return state.update('modifiedData', () => fromJS(action.payload)); + } + default: + return state; + } +}; + +export default reducer; +export { initialState }; diff --git a/packages/strapi-plugin-versioning/src/extensions/content-manager/admin/src/translations/en.json b/packages/strapi-plugin-versioning/src/extensions/content-manager/admin/src/translations/en.json index afc7ff8..e7a259b 100644 --- a/packages/strapi-plugin-versioning/src/extensions/content-manager/admin/src/translations/en.json +++ b/packages/strapi-plugin-versioning/src/extensions/content-manager/admin/src/translations/en.json @@ -1,17 +1,14 @@ { - "api.id": "API ID", - "models": "Collection Types", - "models.numbered": "Collection Types ({number})", - "groups": "Groups", - "groups.numbered": "Groups ({number})", "EditRelations.title": "Relational data", + "api.id": "API ID", "components.AddFilterCTA.add": "Filters", "components.AddFilterCTA.hide": "Filters", "components.DraggableAttr.edit": "Click to edit", "components.DynamicZone.add-compo": "Add to {componentName}", - "components.DynamicZone.pick-compo": "Pick one component", - "components.DynamicZone.missing.singular": "There is {count} missing component", "components.DynamicZone.missing.plural": "There is {count} missing components", + "components.DynamicZone.missing.singular": "There is {count} missing component", + "components.DynamicZone.pick-compo": "Pick one component", + "components.DynamicZone.required": "Component is required", "components.EmptyAttributesBlock.button": "Go to settings page", "components.EmptyAttributesBlock.description": "You can change your settings", "components.FieldItem.linkToComponentLayout": "Set the component's layout", @@ -21,86 +18,95 @@ "components.FiltersPickWrapper.PluginHeader.description": "Set the conditions to apply to filter the entries", "components.FiltersPickWrapper.PluginHeader.title.filter": "Filters", "components.FiltersPickWrapper.hide": "Hide", - "components.notification.info.minimum-requirement": "A field has been added to match the minimum requirement", - "components.notification.info.maximum-requirement": "You have already reached the maximum number of fields", - "components.empty-repeatable": "No entry yet. Click on the button below to add one.", - "components.reset-entry": "Reset entry", "components.LimitSelect.itemsPerPage": "Items per page", + "components.NotAllowedInput.text": "No permissions to see this field", "components.Search.placeholder": "Search for an entry...", - - "components.SettingsViewWrapper.pluginHeader.title": "Configure the view - {name}", + "components.Select.draft-info-title": "State: Draft", + "components.Select.publish-info-title": "State: Published", "components.SettingsViewWrapper.pluginHeader.description.edit-settings": "Customize how the edit view will look like.", "components.SettingsViewWrapper.pluginHeader.description.list-settings": "Define the settings of the list view.", - + "components.SettingsViewWrapper.pluginHeader.title": "Configure the view - {name}", "components.TableDelete.delete": "Delete all", "components.TableDelete.deleteSelected": "Delete selected", "components.TableDelete.entries.plural": "{number} entries selected", "components.TableDelete.entries.singular": "{number} entry selected", - "components.TableEmpty.withFilters": "There is no {contentType} with the applied filters...", - "components.TableEmpty.withSearch": "There is no {contentType} corresponding to the search ({search})...", - "components.TableEmpty.withoutFilter": "There is no {contentType}...", - - "components.uid.available": "available", + "components.TableEmpty.withFilters": "There are no {contentType} with the applied filters...", + "components.TableEmpty.withSearch": "There are no {contentType} corresponding to the search ({search})...", + "components.TableEmpty.withoutFilter": "There are no {contentType}...", + "components.empty-repeatable": "No entry yet. Click on the button below to add one.", + "components.repeatable.reorder.error": "An error occurred while reordering your component's field, please try again", + "components.notification.info.maximum-requirement": "You have already reached the maximum number of fields", + "components.notification.info.minimum-requirement": "A field has been added to match the minimum requirement", + "components.reset-entry": "Reset entry", "components.uid.apply": "apply", + "components.uid.available": "available", "components.uid.regenerate": "regenerate", "components.uid.suggested": "suggested", "components.uid.unavailable": "unavailable", - + "containers.Edit.Link.Fields": "Edit the fields", + "containers.Edit.Link.Layout": "Configure the layout", + "containers.Edit.Link.Model": "Edit the collection-type", "containers.Edit.addAnItem": "Add an item...", - "containers.Edit.pluginHeader.title.new": "Create an entry", "containers.Edit.clickToJump": "Click to jump to the entry", "containers.Edit.delete": "Delete", + "containers.Edit.delete-entry": "Delete this entry", "containers.Edit.editing": "Editing...", + "containers.Edit.information": "Information", + "containers.Edit.information.by": "By", + "containers.Edit.information.draftVersion": "draft version", + "containers.Edit.information.editing": "Editing", + "containers.Edit.information.lastUpdate": "Last update", + "containers.Edit.information.publishedVersion": "published version", + "containers.Edit.pluginHeader.title.new": "Create an entry", "containers.Edit.reset": "Reset", "containers.Edit.returnList": "Return to list", "containers.Edit.seeDetails": "Details", "containers.Edit.submit": "Save", - "containers.Edit.Link.Layout": "Configure the layout", - "containers.Edit.Link.Fields": "Edit the fields", - "containers.Edit.Link.Model": "Edit the collection-type", + "containers.EditSettingsView.modal-form.edit-field": "Edit the field", + "containers.EditView.add.new": "ADD NEW ENTRY", + "containers.EditView.components.missing.plural": "There is {count} missing components", + "containers.EditView.components.missing.singular": "There is {count} missing component", "containers.EditView.notification.errors": "The form contains some errors", - "containers.EditView.revert": "Revert", - "containers.EditView.versions": "Versions", "containers.Home.introduction": "To edit your entries go to the specific link in the left menu. This plugin doesn't have a proper way to edit settings and it's still under active development.", "containers.Home.pluginHeaderDescription": "Manage your entries through a powerful and beautiful interface.", "containers.Home.pluginHeaderTitle": "Content Manager", - "containers.List.addAnEntry": "Add New {entity}", + "containers.List.draft": "Draft", "containers.List.errorFetchRecords": "Error", "containers.List.pluginHeaderDescription": "{label} entries found", "containers.List.pluginHeaderDescription.singular": "{label} entry found", + "containers.List.published": "Published", "containers.ListPage.displayedFields": "Displayed Fields", - + "containers.ListPage.items.plural": "items", + "containers.ListPage.items.singular": "item", + "containers.ListPage.table-headers.published_at": "State", "containers.ListSettingsView.modal-form.edit-label": "Edit the label", - "containers.EditSettingsView.modal-form.edit-field": "Edit the field", - "containers.SettingPage.add.field": "Insert another field", "containers.SettingPage.add.relational-field": "Insert another relational field", "containers.SettingPage.attributes": "Attributes fields", "containers.SettingPage.attributes.description": "Define the order of the attributes", "containers.SettingPage.editSettings.description": "Drag & drop the fields to build the layout", - "containers.SettingPage.editSettings.title": "Edit view (settings)", "containers.SettingPage.editSettings.entry.title": "Entry title", "containers.SettingPage.editSettings.entry.title.description": "Set the displayed field of your entry", + "containers.SettingPage.editSettings.relation-field.description": "Set the displayed field in both the edit and list views", + "containers.SettingPage.editSettings.title": "Edit view (settings)", + "containers.SettingPage.layout": "Layout", "containers.SettingPage.listSettings.description": "Configure the options for this collection type", "containers.SettingPage.listSettings.title": "List view (settings)", "containers.SettingPage.pluginHeaderDescription": "Configure the specific settings for this Collection Type", "containers.SettingPage.relations": "Relational fields", "containers.SettingPage.settings": "Settings", - "containers.SettingPage.layout": "Layout", + "containers.EditView.revert": "Revert", "containers.SettingPage.view": "View", - "containers.EditView.add.new": "ADD NEW ENTRY", - "containers.EditView.components.missing.singular": "There is {count} missing component", - "containers.EditView.components.missing.plural": "There is {count} missing components", "containers.SettingViewModel.pluginHeader.title": "Content Manager - {name}", "containers.SettingsPage.Block.contentType.description": "Configure the specific settings", "containers.SettingsPage.Block.contentType.title": "Collection Types", "containers.SettingsPage.Block.generalSettings.description": "Configure the default options for your Collection Types", "containers.SettingsPage.Block.generalSettings.title": "General", "containers.SettingsPage.pluginHeaderDescription": "Configure the settings for all your Collection types and Groups", - "containers.SettingsView.list.title": "Display configurations", "containers.SettingsView.list.subtitle": "Configure the layout and display of your Collection types and groups", - + "containers.SettingsView.list.title": "Display configurations", + "containers.EditView.versions": "Versions", "emptyAttributes.button": "Go to collection type builder", "emptyAttributes.description": "Add your first field to your Collection Type", "emptyAttributes.title": "There are no fields yet", @@ -122,10 +128,9 @@ "error.validation.min": "The value is too low.", "error.validation.minLength": "The value is too short.", "error.validation.minSupMax": "Can't be superior", - "error.validation.regex": "The value not match the regex.", + "error.validation.regex": "The value does not match the regex.", "error.validation.required": "This value input is required.", "error.relation.fetch": "Unable to get versions data", - "form.Input.bulkActions": "Enable bulk actions", "form.Input.defaultSort": "Default sort attribute", "form.Input.description": "Description", @@ -142,25 +147,34 @@ "form.Input.search.field": "Enable search on this field", "form.Input.sort.field": "Enable sort on this field", "form.Input.wysiwyg": "Display as WYSIWYG", - "global.displayedFields": "Displayed Fields", - + "groups": "Groups", + "groups.numbered": "Groups ({number})", + "models": "Collection Types", + "models.numbered": "Collection Types ({number})", "notification.error.displayedFields": "You need at least one displayed field", "notification.error.relationship.fetch": "An error occurred during relationship fetch.", "notification.info.SettingPage.disableSort": "You need to have one attribute with the sorting allowed", + "notification.info.minimumFields": "You need to have at least one field displayed", + "notification.upload.error": "An error has occurred while uploading your files", "pageNotFound": "Page not found", + "permissions.not-allowed.create": "You are not allowed to create a document", + "permissions.not-allowed.update": "You are not allowed to see this document", "plugin.description.long": "Quick way to see, edit and delete the data in your database.", "plugin.description.short": "Quick way to see, edit and delete the data in your database.", "popUpWarning.bodyMessage.contentType.delete": "Are you sure you want to delete this entry?", "popUpWarning.bodyMessage.contentType.delete.all": "Are you sure you want to delete theses entries?", - "popUpWarning.button.cancel": "Cancel", - "popUpWarning.button.confirm": "Confirm", - "popUpWarning.title": "Please confirm", "popUpWarning.warning.cancelAllSettings": "Are you sure you want to cancel your modifications?", + "popUpWarning.warning.publish-question": "Do you still want to publish it?", + "popUpWarning.warning.unpublish": "Unpublish this content will

automatically change it to a draft.", + "popUpWarning.warning.unpublish-question": "Are you sure you want to unpublish it?", "popUpWarning.warning.updateAllSettings": "This will modify all your settings", + "popUpwarning.warning.has-draft-relations.button-confirm": "Yes, publish", + "popUpwarning.warning.has-draft-relations.message.plural": "{count} of your content relations are not published yet.

It might engender broken links and errors on your project.", + "popUpwarning.warning.has-draft-relations.message.singular": "{count} of your content relations is not published yet.

It might engender broken links and errors on your project.", + "popUpwarning.warning.has-draft-relations.second-message": "It might engender broken links and errors on your project.", "success.record.delete": "Deleted", + "success.record.publish": "Published", "success.record.save": "Saved", - "notification.info.minimumFields": "You need to have at least one field displayed", - "notification.upload.error": "An error has occured while uploading your files" + "success.record.unpublish": "Unpublished" } - diff --git a/packages/strapi-plugin-versioning/src/extensions/content-manager/config/routes.json b/packages/strapi-plugin-versioning/src/extensions/content-manager/config/routes.json index a8cf56e..29cdfab 100644 --- a/packages/strapi-plugin-versioning/src/extensions/content-manager/config/routes.json +++ b/packages/strapi-plugin-versioning/src/extensions/content-manager/config/routes.json @@ -11,123 +11,334 @@ { "method": "GET", "path": "/content-types", - "handler": "ContentTypes.listContentTypes", + "handler": "content-types.findContentTypes", "config": { "policies": [] } }, { "method": "GET", - "path": "/content-types/:uid", - "handler": "ContentTypes.findContentType", + "path": "/content-types/:uid/configuration", + "handler": "content-types.findContentTypeConfiguration", "config": { "policies": [] } }, { "method": "PUT", - "path": "/content-types/:uid", - "handler": "ContentTypes.updateContentType", + "path": "/content-types/:uid/configuration", + "handler": "content-types.updateContentTypeConfiguration", "config": { - "policies": [] + "policies": [ + "admin::isAuthenticatedAdmin" + ] } }, { "method": "GET", "path": "/components", - "handler": "Components.listComponents", + "handler": "components.findComponents", "config": { "policies": [] } }, { "method": "GET", - "path": "/components/:uid", - "handler": "Components.findComponent", + "path": "/components/:uid/configuration", + "handler": "components.findComponentConfiguration", "config": { "policies": [] } }, { "method": "PUT", - "path": "/components/:uid", - "handler": "Components.updateComponent", + "path": "/components/:uid/configuration", + "handler": "components.updateComponentConfiguration", "config": { "policies": [] } }, { "method": "POST", - "path": "/explorer/uid/generate", - "handler": "ContentManager.generateUID", + "path": "/uid/generate", + "handler": "uid.generateUID", "config": { "policies": [] } }, { "method": "POST", - "path": "/explorer/uid/check-availability", - "handler": "ContentManager.checkUIDAvailability", + "path": "/uid/check-availability", + "handler": "uid.checkUIDAvailability", "config": { "policies": [] } }, + { + "method": "POST", + "path": "/relations/:model/:targetField", + "handler": "relations.find", + "config": { + "policies": [ + "admin::isAuthenticatedAdmin", + [ + "plugins::content-manager.hasPermissions", + [ + "plugins::content-manager.explorer.create", + "plugins::content-manager.explorer.update" + ], + { + "hasAtLeastOne": true + } + ] + ] + } + }, { "method": "GET", - "path": "/explorer/:model", - "handler": "ContentManager.find", + "path": "/single-types/:model", + "handler": "single-types.find", + "config": { + "policies": [ + "routing", + "admin::isAuthenticatedAdmin", + [ + "plugins::content-manager.hasPermissions", + [ + "plugins::content-manager.explorer.read" + ] + ] + ] + } + }, + { + "method": "PUT", + "path": "/single-types/:model", + "handler": "single-types.createOrUpdate", "config": { - "policies": ["routing"] + "policies": [ + "routing", + "admin::isAuthenticatedAdmin", + [ + "plugins::content-manager.hasPermissions", + [ + "plugins::content-manager.explorer.create", + "plugins::content-manager.explorer.update" + ], + { + "hasAtLeastOne": true + } + ] + ] + } + }, + { + "method": "DELETE", + "path": "/single-types/:model", + "handler": "single-types.delete", + "config": { + "policies": [ + "routing", + "admin::isAuthenticatedAdmin", + [ + "plugins::content-manager.hasPermissions", + [ + "plugins::content-manager.explorer.delete" + ] + ] + ] + } + }, + { + "method": "POST", + "path": "/single-types/:model/actions/publish", + "handler": "single-types.publish", + "config": { + "policies": [ + "routing", + "plugins::content-manager.has-draft-and-publish", + "admin::isAuthenticatedAdmin", + [ + "plugins::content-manager.hasPermissions", + [ + "plugins::content-manager.explorer.publish" + ] + ] + ] + } + }, + { + "method": "POST", + "path": "/single-types/:model/actions/unpublish", + "handler": "single-types.unpublish", + "config": { + "policies": [ + "routing", + "plugins::content-manager.has-draft-and-publish", + "admin::isAuthenticatedAdmin", + [ + "plugins::content-manager.hasPermissions", + [ + "plugins::content-manager.explorer.publish" + ] + ] + ] } }, { "method": "GET", - "path": "/explorer/:model/count", - "handler": "ContentManager.count", + "path": "/collection-types/:model/:id/:targetField", + "handler": "collection-types.previewManyRelations", "config": { - "policies": ["routing"] + "policies": [ + "routing", + "admin::isAuthenticatedAdmin", + [ + "plugins::content-manager.hasPermissions", + [ + "plugins::content-manager.explorer.read" + ] + ] + ] } }, { "method": "GET", - "path": "/explorer/:model/:id", - "handler": "ContentManager.findOne", + "path": "/collection-types/:model", + "handler": "collection-types.find", "config": { - "policies": ["routing"] + "policies": [ + "routing", + "admin::isAuthenticatedAdmin", + [ + "plugins::content-manager.hasPermissions", + [ + "plugins::content-manager.explorer.read" + ] + ] + ] } }, { "method": "POST", - "path": "/explorer/:model", - "handler": "ContentManager.create", + "path": "/collection-types/:model", + "handler": "collection-types.create", "config": { - "policies": ["routing"] + "policies": [ + "routing", + "admin::isAuthenticatedAdmin", + [ + "plugins::content-manager.hasPermissions", + [ + "plugins::content-manager.explorer.create" + ] + ] + ] + } + }, + { + "method": "GET", + "path": "/collection-types/:model/:id", + "handler": "collection-types.findOne", + "config": { + "policies": [ + "routing", + "admin::isAuthenticatedAdmin", + [ + "plugins::content-manager.hasPermissions", + [ + "plugins::content-manager.explorer.read" + ] + ] + ] } }, { "method": "PUT", - "path": "/explorer/:model/:id", - "handler": "ContentManager.update", + "path": "/collection-types/:model/:id", + "handler": "collection-types.update", "config": { - "policies": ["routing"] + "policies": [ + "routing", + "admin::isAuthenticatedAdmin", + [ + "plugins::content-manager.hasPermissions", + [ + "plugins::content-manager.explorer.update" + ] + ] + ] } }, { "method": "DELETE", - "path": "/explorer/deleteAll/:model", - "handler": "ContentManager.deleteMany", + "path": "/collection-types/:model/:id", + "handler": "collection-types.delete", "config": { - "policies": ["routing"] + "policies": [ + "routing", + "admin::isAuthenticatedAdmin", + [ + "plugins::content-manager.hasPermissions", + [ + "plugins::content-manager.explorer.delete" + ] + ] + ] } }, { - "method": "DELETE", - "path": "/explorer/:model/:id", - "handler": "ContentManager.delete", + "method": "POST", + "path": "/collection-types/:model/:id/actions/publish", + "handler": "collection-types.publish", + "config": { + "policies": [ + "routing", + "plugins::content-manager.has-draft-and-publish", + "admin::isAuthenticatedAdmin", + [ + "plugins::content-manager.hasPermissions", + [ + "plugins::content-manager.explorer.publish" + ] + ] + ] + } + }, + { + "method": "POST", + "path": "/collection-types/:model/:id/actions/unpublish", + "handler": "collection-types.unpublish", + "config": { + "policies": [ + "routing", + "plugins::content-manager.has-draft-and-publish", + "admin::isAuthenticatedAdmin", + [ + "plugins::content-manager.hasPermissions", + [ + "plugins::content-manager.explorer.publish" + ] + ] + ] + } + }, + { + "method": "POST", + "path": "/collection-types/:model/actions/bulkDelete", + "handler": "collection-types.bulkDelete", "config": { - "policies": ["routing"] + "policies": [ + "routing", + "admin::isAuthenticatedAdmin", + [ + "plugins::content-manager.hasPermissions", + [ + "plugins::content-manager.explorer.delete" + ] + ] + ] } } ] } - diff --git a/packages/strapi-plugin-versioning/src/extensions/content-manager/controllers/Versions.ts b/packages/strapi-plugin-versioning/src/extensions/content-manager/controllers/Versions.ts index 777d955..6cf0ada 100644 --- a/packages/strapi-plugin-versioning/src/extensions/content-manager/controllers/Versions.ts +++ b/packages/strapi-plugin-versioning/src/extensions/content-manager/controllers/Versions.ts @@ -1,4 +1,4 @@ -import { Context } from "koa"; +import { Context } from 'koa'; type Content = { created_at: string; @@ -12,23 +12,24 @@ type Version = { id: number; }; +const saveParse = (entity: string) => { + try { + return JSON.parse(entity); + } catch (e) { + return entity; + } +}; + module.exports = { async listEntityVersions(ctx: Context): Promise { const { model, id } = ctx.params; - const versionsForAllContentTypes: Content[] = await global.strapi.plugins[ - "versioning" - ].services.versioning.getVersionsForAllConentTypes(); - - const versionsForCurrentContentType = versionsForAllContentTypes.filter( - (version) => version.content_type == model, + const service = global.strapi.plugins['versioning'].services.versioning; + const versionsForCurrentContentType: Content[] = await service.getVersionsForEntity( + model, + id, ); - - const versionsForCurrentId = versionsForCurrentContentType.filter( - (version) => version.entity_id === parseInt(id), - ); - - return versionsForCurrentId.map((el) => ({ - content: el.entity, + return versionsForCurrentContentType.map((el) => ({ + content: saveParse(el.entity), date: el.date, id: el.id, })); diff --git a/packages/strapi-plugin-versioning/src/middlewares/versions-middleware/index.ts b/packages/strapi-plugin-versioning/src/middlewares/versions-middleware/index.ts index 1062d19..d24733f 100644 --- a/packages/strapi-plugin-versioning/src/middlewares/versions-middleware/index.ts +++ b/packages/strapi-plugin-versioning/src/middlewares/versions-middleware/index.ts @@ -1,21 +1,27 @@ -const versioningService = - global.strapi.plugins["versioning"].services.versioning; +const getVersioningService = () => + global.strapi.plugins['versioning'].services.versioning; module.exports = () => { return { initialize() { + const versioningService = getVersioningService(); global.strapi.app.use(async (ctx, next) => { await next(); - const properMethods = ["PUT", "POST"]; + const properMethods = ['PUT', 'POST']; const model = versioningService.getModelFromCtx(ctx); if ( properMethods.includes(ctx.response.request.method) && versioningService.isModelExists(model) && - ctx.response.message == "OK" + ctx.response.message == 'OK' ) { - const uid = versioningService.findUid(ctx); - const updatedData = await global.strapi.db.query(uid).find(); - versioningService.saveDataInDB(updatedData, uid, ctx.response.body); + const entity = ctx.response.body; + if (entity.id) { + const uid = versioningService.findUid(ctx); + const updatedData = await global.strapi.db + .query(uid) + .findOne({ id: entity.id }); + versioningService.saveDataInDB(updatedData, uid, entity); + } } }); }, diff --git a/packages/strapi-plugin-versioning/src/services/Versioning.ts b/packages/strapi-plugin-versioning/src/services/Versioning.ts index b30bdf6..4db43da 100644 --- a/packages/strapi-plugin-versioning/src/services/Versioning.ts +++ b/packages/strapi-plugin-versioning/src/services/Versioning.ts @@ -1,4 +1,9 @@ -import { Context } from "koa"; +import { Context } from 'koa'; +import { + isObject, + noop, +} from 'lodash'; +import { GlobalQueryResult } from 'strapi-types'; type Entity = any; type Data = Array; @@ -7,7 +12,7 @@ const getModelFromCtx = (ctx: Context): string | undefined => { if (ctx.params?.model) { return ctx.params.model; } - return ctx.url.split("/")[1]; + return ctx.url.split('/')[1]; }; const isModelExists = (model: string | undefined): boolean => { @@ -23,33 +28,67 @@ const findUid = (ctx: Context): string => ctx.params.model ? global.strapi.db.getModel(ctx.params.model).uid : Object.values(global.strapi.models).find( - (el) => el.collectionName == ctx.url.split("/")[1], - )!.uid; + (el) => el.collectionName == ctx.url.split('/')[1], + )!.uid; const saveDataInDB = async ( data: Data, model: string, entity: Entity, ): Promise => { - const knexQueryBuilder = global.strapi.connections.default("versions"); + const knexQueryBuilder = global.strapi.connections.default('versions'); knexQueryBuilder .insert({ date: new Date().toISOString(), content_type: model, - content: JSON.stringify(data), + content: isObject(data) ? JSON.stringify(data) : data, entity_id: entity.id, - entity: JSON.stringify(entity), + entity: isObject(data) ? JSON.stringify(data) : data, }) - .then(); + .then(noop) + .catch(noop); }; const getVersionsForAllConentTypes = async () => { - const knexQueryBuilder = global.strapi.connections.default("versions"); - knexQueryBuilder.select().returning("*").toString(); - return await knexQueryBuilder; + const knexQueryBuilder = global.strapi.connections.default('versions'); + knexQueryBuilder.select().returning('*').toString(); + return knexQueryBuilder; +}; + +const isSingleType = (kind: 'singleType' | 'collectionType') => kind === 'singleType'; + +const knexBuildQuery = (contentType: string, id: string): Promise => { + const builder = global.strapi.connections.default('versions'); + builder + .select() + .where({ content_type: contentType, entity_id: id }) + .returning('*') + .toString(); + return builder; +}; + +const getVersionsForSingleType = async (model: GlobalQueryResult, contentType: string) => { + const entity = await model.findOne({}); + if (entity) { + return knexBuildQuery(contentType, entity.id) + } + return Promise.resolve([]); +}; + +const getVersionsForEntity = async (contentType: string, id: string) => { + const model = global.strapi.query(contentType); + if (model) { + const { model: { kind } } = model; + if (isSingleType(kind)) { + return getVersionsForSingleType(model, contentType); + } + return knexBuildQuery(contentType, id); + } + return (global.strapi).errors.badRequest('Content not exists'); }; module.exports = { + getVersionsForEntity, saveDataInDB, getVersionsForAllConentTypes, isModelExists, diff --git a/packages/strapi-types/schemas/model.ts b/packages/strapi-types/schemas/model.ts index 2c461ae..0057c91 100644 --- a/packages/strapi-types/schemas/model.ts +++ b/packages/strapi-types/schemas/model.ts @@ -44,6 +44,7 @@ export type Attribute = export type Model = BookshelfModel & { collectionName: string; + kind: 'singleType' | 'collectionType' modelName: string; databaseName: string; primaryKey: string; diff --git a/packages/strapi-types/strapi-global/query.ts b/packages/strapi-types/strapi-global/query.ts index 8c128b2..db784e0 100644 --- a/packages/strapi-types/strapi-global/query.ts +++ b/packages/strapi-types/strapi-global/query.ts @@ -1,4 +1,4 @@ -import { Model } from "../schemas"; +import { Model } from '../schemas'; type Plugin = any; type Filter = any; @@ -8,31 +8,32 @@ type Association = { type: string; model: string; via: string | undefined; - nature: "oneWay" | string; + nature: 'oneWay' | string; autoPopulate: boolean; dominant: boolean; plugin: Plugin | undefined; filter: Filter | undefined; }; -type AsyncFunction = (p: any) => Promise; +type AsyncFunction = (p: any) => Promise; +type AsyncFunctionMultiple = (p: any) => Promise; export type StrapiGlobalQuery = (s: string) => GlobalQueryResult | undefined; export type GlobalQueryResult = { model: Model; - orm: "bookshelf" | "mongoose"; - primaryKey: "id" | string; + orm: 'bookshelf' | 'mongoose'; + primaryKey: 'id' | string; associations: Association[]; custom: unknown; create: AsyncFunction; createMany: any; update: AsyncFunction; delete: AsyncFunction; - find: AsyncFunction; + find: AsyncFunctionMultiple; findOne: AsyncFunction; count: AsyncFunction; - search: AsyncFunction; + search: AsyncFunctionMultiple; countSearch: AsyncFunction; - findPage: AsyncFunction; - searchPage: AsyncFunction; + findPage: AsyncFunctionMultiple; + searchPage: AsyncFunctionMultiple; }; diff --git a/tsconfig.json b/tsconfig.json index 085ac31..b15440a 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -5,7 +5,7 @@ "esnext", "dom" ], - "target": "es5", + "target": "es2018", "module": "commonjs", "jsx": "react", "moduleResolution": "node",