diff --git a/packages/core/content-manager/admin/src/history/components/VersionContent.tsx b/packages/core/content-manager/admin/src/history/components/VersionContent.tsx index 03370dd047..01b3071949 100644 --- a/packages/core/content-manager/admin/src/history/components/VersionContent.tsx +++ b/packages/core/content-manager/admin/src/history/components/VersionContent.tsx @@ -10,15 +10,102 @@ import { GridItem, Typography, } from '@strapi/design-system'; +import { Schema } from '@strapi/types'; +import pipe from 'lodash/fp/pipe'; import { useIntl } from 'react-intl'; +import { useDoc } from '../../hooks/useDocument'; import { useTypedSelector } from '../../modules/hooks'; -import { useHistoryContext } from '../pages/History'; +import { + prepareTempKeys, + removeFieldsThatDontExistOnSchema, +} from '../../pages/EditView/utils/data'; +import { HistoryContextValue, useHistoryContext } from '../pages/History'; import { VersionInputRenderer } from './VersionInputRenderer'; +import type { Metadatas } from '../../../../shared/contracts/content-types'; +import type { GetInitData } from '../../../../shared/contracts/init'; +import type { ComponentsDictionary, Document } from '../../hooks/useDocument'; import type { EditFieldLayout } from '../../hooks/useDocumentLayout'; +const createLayoutFromFields = (fields: T[]) => { + return ( + fields + .reduce>((rows, field) => { + if (field.type === 'dynamiczone') { + // Dynamic zones take up all the columns in a row + rows.push([field]); + + return rows; + } + + if (!rows[rows.length - 1]) { + // Create a new row if there isn't one available + rows.push([]); + } + + // Push fields to the current row, they wrap and handle their own column size + rows[rows.length - 1].push(field); + + return rows; + }, []) + // Map the rows to panels + .map((row) => [row]) + ); +}; + +/* ------------------------------------------------------------------------------------------------- + * getRemainingFieldsLayout + * -----------------------------------------------------------------------------------------------*/ + +interface GetRemainingFieldsLayoutOptions + extends Pick, + Pick { + schemaAttributes: HistoryContextValue['schema']['attributes']; + metadatas: Metadatas; +} + +/** + * Build a layout for the fields that are were deleted from the edit view layout + * via the configure the view page. This layout will be merged with the main one. + * Those fields would be restored if the user restores the history version, which is why it's + * important to show them, even if they're not in the normal layout. + */ +function getRemaingFieldsLayout({ + layout, + metadatas, + schemaAttributes, + fieldSizes, +}: GetRemainingFieldsLayoutOptions) { + const fieldsInLayout = layout.flatMap((panel) => + panel.flatMap((row) => row.flatMap((field) => field.name)) + ); + const remainingFields = Object.entries(metadatas).reduce( + (currentRemainingFields, [name, field]) => { + // Make sure we do not fields that are not visible, e.g. "id" + if (!fieldsInLayout.includes(name) && field.edit.visible === true) { + const attribute = schemaAttributes[name]; + // @ts-expect-error not sure why attribute causes type error + currentRemainingFields.push({ + attribute, + type: attribute.type, + visible: true, + disabled: true, + label: field.edit.label || name, + name: name, + size: fieldSizes[attribute.type].default ?? 12, + }); + } + + return currentRemainingFields; + }, + [] + ); + + return createLayoutFromFields(remainingFields); +} + /* ------------------------------------------------------------------------------------------------- * FormPanel * -----------------------------------------------------------------------------------------------*/ @@ -70,14 +157,16 @@ const FormPanel = ({ panel }: { panel: EditFieldLayout[][] }) => { * -----------------------------------------------------------------------------------------------*/ type UnknownField = EditFieldLayout & { shouldIgnoreRBAC: boolean }; + const VersionContent = () => { const { formatMessage } = useIntl(); const { fieldSizes } = useTypedSelector((state) => state['content-manager'].app); - const { version, layout } = useHistoryContext('VersionContent', (state) => ({ - version: state.selectedVersion, - layout: state.layout, - })); + const version = useHistoryContext('VersionContent', (state) => state.selectedVersion); + const layout = useHistoryContext('VersionContent', (state) => state.layout); + const configuration = useHistoryContext('VersionContent', (state) => state.configuration); + const schema = useHistoryContext('VersionContent', (state) => state.schema); + // Build a layout for the unknown fields section const removedAttributes = version.meta.unknownAttributes.removed; const removedAttributesAsFields = Object.entries(removedAttributes).map( ([attributeName, attribute]) => { @@ -95,34 +184,43 @@ const VersionContent = () => { return field; } ); - const unknownFieldsLayout = removedAttributesAsFields - .reduce>((rows, field) => { - if (field.type === 'dynamiczone') { - // Dynamic zones take up all the columns in a row - rows.push([field]); + const unknownFieldsLayout = createLayoutFromFields(removedAttributesAsFields); - return rows; - } + // Build a layout for the fields that are were deleted from the layout + const remainingFieldsLayout = getRemaingFieldsLayout({ + metadatas: configuration.contentType.metadatas, + layout, + schemaAttributes: schema.attributes, + fieldSizes, + }); - if (!rows[rows.length - 1]) { - // Create a new row if there isn't one available - rows.push([]); - } + const { components } = useDoc(); - // Push fields to the current row, they wrap and handle their own column size - rows[rows.length - 1].push(field); + /** + * Transform the data before passing it to the form so that each field + * has a uniquely generated key + */ + const transformedData = React.useMemo(() => { + const transform = + (schemaAttributes: Schema.Attributes, components: ComponentsDictionary = {}) => + (document: Omit) => { + const schema = { attributes: schemaAttributes }; + const transformations = pipe( + removeFieldsThatDontExistOnSchema(schema), + prepareTempKeys(schema, components) + ); + return transformations(document); + }; - return rows; - }, []) - // Map the rows to panels - .map((row) => [row]); + return transform(version.schema, components)(version.data); + }, [components, version.data, version.schema]); return ( -
+ - {layout.map((panel, index) => { + {[...layout, ...remainingFieldsLayout].map((panel, index) => { return ; })} @@ -170,4 +268,4 @@ const VersionContent = () => { ); }; -export { VersionContent }; +export { VersionContent, getRemaingFieldsLayout }; diff --git a/packages/core/content-manager/admin/src/history/components/VersionInputRenderer.tsx b/packages/core/content-manager/admin/src/history/components/VersionInputRenderer.tsx index cf0641acf9..f0189ca6f1 100644 --- a/packages/core/content-manager/admin/src/history/components/VersionInputRenderer.tsx +++ b/packages/core/content-manager/admin/src/history/components/VersionInputRenderer.tsx @@ -15,7 +15,9 @@ import styled from 'styled-components'; import { COLLECTION_TYPES } from '../../constants/collections'; import { useDocumentRBAC } from '../../features/DocumentRBAC'; import { useDoc } from '../../hooks/useDocument'; +import { useDocLayout } from '../../hooks/useDocumentLayout'; import { useLazyComponents } from '../../hooks/useLazyComponents'; +import { useTypedSelector } from '../../modules/hooks'; import { DocumentStatus } from '../../pages/EditView/components/DocumentStatus'; import { BlocksInput } from '../../pages/EditView/components/FormInputs/BlocksInput/BlocksInput'; import { ComponentInput } from '../../pages/EditView/components/FormInputs/Component/Input'; @@ -30,6 +32,8 @@ import { useFieldHint } from '../../pages/EditView/components/InputRenderer'; import { getRelationLabel } from '../../utils/relations'; import { useHistoryContext } from '../pages/History'; +import { getRemaingFieldsLayout } from './VersionContent'; + import type { EditFieldLayout } from '../../hooks/useDocumentLayout'; import type { RelationsFieldProps } from '../../pages/EditView/components/FormInputs/Relations'; import type { RelationResult } from '../../services/relations'; @@ -233,10 +237,11 @@ const VersionInputRenderer = ({ ...props }: VersionInputRendererProps) => { const { formatMessage } = useIntl(); - const { version } = useHistoryContext('VersionContent', (state) => ({ - version: state.selectedVersion, - })); - const { id } = useDoc(); + const version = useHistoryContext('VersionContent', (state) => state.selectedVersion); + const configuration = useHistoryContext('VersionContent', (state) => state.configuration); + const fieldSizes = useTypedSelector((state) => state['content-manager'].app.fieldSizes); + + const { id, components } = useDoc(); const isFormDisabled = useForm('InputRenderer', (state) => state.disabled); const isInDynamicZone = useDynamicZone('isInDynamicZone', (state) => state.isInDynamicZone); @@ -261,6 +266,9 @@ const VersionInputRenderer = ({ ); const hint = useFieldHint(providedHint, props.attribute); + const { + edit: { components: componentsLayout }, + } = useDocLayout(); if (!visible) { return null; @@ -353,13 +361,24 @@ const VersionInputRenderer = ({ case 'blocks': return ; case 'component': + const { layout } = componentsLayout[props.attribute.component]; + // Components can only have one panel, so only save the first layout item + const [remainingFieldsLayout] = getRemaingFieldsLayout({ + layout: [layout], + metadatas: configuration.components[props.attribute.component].metadatas, + fieldSizes, + schemaAttributes: components[props.attribute.component].attributes, + }); + return ( } - /> + > + {(inputProps) => } + ); case 'dynamiczone': return ; diff --git a/packages/core/content-manager/admin/src/history/pages/History.tsx b/packages/core/content-manager/admin/src/history/pages/History.tsx index 7a2a0a47e6..c3b758270b 100644 --- a/packages/core/content-manager/admin/src/history/pages/History.tsx +++ b/packages/core/content-manager/admin/src/history/pages/History.tsx @@ -11,13 +11,17 @@ import { PERMISSIONS } from '../../constants/plugin'; import { DocumentRBAC } from '../../features/DocumentRBAC'; import { useDocument } from '../../hooks/useDocument'; import { type EditLayout, useDocumentLayout } from '../../hooks/useDocumentLayout'; +import { useGetContentTypeConfigurationQuery } from '../../services/contentTypes'; import { buildValidParams } from '../../utils/api'; import { VersionContent } from '../components/VersionContent'; import { VersionHeader } from '../components/VersionHeader'; import { VersionsList } from '../components/VersionsList'; import { useGetHistoryVersionsQuery } from '../services/historyVersion'; -import type { ContentType } from '../../../../shared/contracts/content-types'; +import type { + ContentType, + FindContentTypeConfiguration, +} from '../../../../shared/contracts/content-types'; import type { HistoryVersionDataResponse, GetHistoryVersions, @@ -32,6 +36,7 @@ interface HistoryContextValue { contentType: UID.ContentType; id?: string; // null for single types layout: EditLayout['layout']; + configuration: FindContentTypeConfiguration.Response['data']; selectedVersion: HistoryVersionDataResponse; // Errors are handled outside of the provider, so we exclude errors from the response type versions: Extract }>; @@ -71,6 +76,8 @@ const HistoryPage = () => { settings: { displayName, mainField }, }, } = useDocumentLayout(slug!); + const { data: configuration, isLoading: isLoadingConfiguration } = + useGetContentTypeConfigurationQuery(slug!); // Parse state from query params const [{ query }] = useQueryParams<{ @@ -114,7 +121,13 @@ const HistoryPage = () => { return ; } - if (isLoadingDocument || isLoadingLayout || versionsResponse.isFetching || isStaleRequest) { + if ( + isLoadingDocument || + isLoadingLayout || + versionsResponse.isFetching || + isStaleRequest || + isLoadingConfiguration + ) { return ; } @@ -141,6 +154,7 @@ const HistoryPage = () => { !layout || !schema || !selectedVersion || + !configuration || // This should not happen as it's covered by versionsResponse.isError, but we need it for TS versionsResponse.data.error ) { @@ -165,6 +179,7 @@ const HistoryPage = () => { id={documentId} schema={schema} layout={layout} + configuration={configuration} selectedVersion={selectedVersion} versions={versionsResponse.data} page={page} diff --git a/packages/core/content-manager/admin/src/hooks/useDocumentLayout.ts b/packages/core/content-manager/admin/src/hooks/useDocumentLayout.ts index 130352aa5f..54c8c727e0 100644 --- a/packages/core/content-manager/admin/src/hooks/useDocumentLayout.ts +++ b/packages/core/content-manager/admin/src/hooks/useDocumentLayout.ts @@ -224,7 +224,7 @@ type LayoutData = FindContentTypeConfiguration.Response['data']; /** * @internal * @description takes the configuration data, the schema & the components used in the schema and formats the edit view - * vesions of the schema & components. This is then used to redner the edit view of the content-type. + * versions of the schema & components. This is then used to render the edit view of the content-type. */ const formatEditLayout = ( data: LayoutData, diff --git a/packages/core/content-manager/admin/src/pages/EditView/components/FormInputs/Component/Input.tsx b/packages/core/content-manager/admin/src/pages/EditView/components/FormInputs/Component/Input.tsx index 46320a8683..1dea454dca 100644 --- a/packages/core/content-manager/admin/src/pages/EditView/components/FormInputs/Component/Input.tsx +++ b/packages/core/content-manager/admin/src/pages/EditView/components/FormInputs/Component/Input.tsx @@ -10,7 +10,7 @@ import { EditFieldLayout } from '../../../../../hooks/useDocumentLayout'; import { getTranslation } from '../../../../../utils/translations'; import { transformDocument } from '../../../utils/data'; import { createDefaultForm } from '../../../utils/forms'; -import { InputRendererProps } from '../../InputRenderer'; +import { type InputRendererProps } from '../../InputRenderer'; import { Initializer } from './Initializer'; import { NonRepeatableComponent } from './NonRepeatable'; @@ -20,7 +20,12 @@ interface ComponentInputProps extends Omit, 'size' | 'hint'>, Pick { labelAction?: React.ReactNode; - renderInput?: (props: InputRendererProps) => React.ReactNode; + children: (props: InputRendererProps) => React.ReactNode; + /** + * We need layout to come from the props, and not via a hook, because Content History needs + * a way to modify the normal component layout to add hidden fields. + */ + layout: EditFieldLayout[][]; } const ComponentInput = ({ @@ -90,15 +95,14 @@ const ComponentInput = ({ )} {!attribute.repeatable && field.value ? ( - + + {props.children} + ) : null} {attribute.repeatable && ( - + + {props.children} + )} diff --git a/packages/core/content-manager/admin/src/pages/EditView/components/FormInputs/Component/NonRepeatable.tsx b/packages/core/content-manager/admin/src/pages/EditView/components/FormInputs/Component/NonRepeatable.tsx index 158dfcec0c..65be02ca92 100644 --- a/packages/core/content-manager/admin/src/pages/EditView/components/FormInputs/Component/NonRepeatable.tsx +++ b/packages/core/content-manager/admin/src/pages/EditView/components/FormInputs/Component/NonRepeatable.tsx @@ -1,28 +1,20 @@ import { useField } from '@strapi/admin/strapi-admin'; import { Box, Flex, Grid, GridItem } from '@strapi/design-system'; -import { useDocLayout } from '../../../../../hooks/useDocumentLayout'; -import { InputRenderer } from '../../InputRenderer'; import { ComponentProvider, useComponent } from '../ComponentContext'; import type { ComponentInputProps } from './Input'; -interface NonRepeatableComponentProps extends Omit {} +type NonRepeatableComponentProps = Omit; const NonRepeatableComponent = ({ attribute, name, - renderInput = InputRenderer, + children, + layout, }: NonRepeatableComponentProps) => { - const { - edit: { components }, - } = useDocLayout(); - const { value } = useField(name); const level = useComponent('NonRepeatableComponent', (state) => state.level); - - const { layout } = components[attribute.component]; - const isNested = level > 0; return ( @@ -51,7 +43,7 @@ const NonRepeatableComponent = ({ return ( - {renderInput({ ...field, name: completeFieldName })} + {children({ ...field, name: completeFieldName })} ); })} diff --git a/packages/core/content-manager/admin/src/pages/EditView/components/FormInputs/Component/Repeatable.tsx b/packages/core/content-manager/admin/src/pages/EditView/components/FormInputs/Component/Repeatable.tsx index 34bdbf9b54..7443962e9c 100644 --- a/packages/core/content-manager/admin/src/pages/EditView/components/FormInputs/Component/Repeatable.tsx +++ b/packages/core/content-manager/admin/src/pages/EditView/components/FormInputs/Component/Repeatable.tsx @@ -9,12 +9,12 @@ import { Accordion, AccordionContent as DSAccordionContent, AccordionToggle, - Grid, - GridItem, IconButton, Typography, KeyboardNavigable, useComposedRefs, + GridItem, + Grid, } from '@strapi/design-system'; import { Plus, Drag, Trash } from '@strapi/icons'; import { getEmptyImage } from 'react-dnd-html5-backend'; @@ -24,13 +24,11 @@ import styled from 'styled-components'; import { ItemTypes } from '../../../../../constants/dragAndDrop'; import { useDoc } from '../../../../../hooks/useDocument'; -import { useDocLayout } from '../../../../../hooks/useDocumentLayout'; import { useDragAndDrop, type UseDragAndDropOptions } from '../../../../../hooks/useDragAndDrop'; import { getIn } from '../../../../../utils/objects'; import { getTranslation } from '../../../../../utils/translations'; import { transformDocument } from '../../../utils/data'; import { createDefaultForm } from '../../../utils/forms'; -import { InputRenderer } from '../../InputRenderer'; import { ComponentProvider, useComponent } from '../ComponentContext'; import { Initializer } from './Initializer'; @@ -42,14 +40,15 @@ import type { Schema } from '@strapi/types'; * RepeatableComponent * -----------------------------------------------------------------------------------------------*/ -interface RepeatableComponentProps extends Omit {} +type RepeatableComponentProps = Omit; const RepeatableComponent = ({ attribute, disabled, name, mainField, - renderInput, + children, + layout, }: RepeatableComponentProps) => { const { toggleNotification } = useNotification(); const { formatMessage } = useIntl(); @@ -211,6 +210,7 @@ const RepeatableComponent = ({ {value.map(({ __temp_key__: key, id }, index) => { + const nameWithIndex = `${name}.${index}`; return ( { @@ -238,7 +237,29 @@ const RepeatableComponent = ({ onCancel={handleCancel} onDropItem={handleDropItem} onGrabItem={handleGrabItem} - /> + > + {layout.map((row, index) => { + return ( + + {row.map(({ size, ...field }) => { + /** + * Layouts are built from schemas so they don't understand the complete + * schema tree, for components we append the parent name to the field name + * because this is the structure for the data & permissions also understand + * the nesting involved. + */ + const completeFieldName = `${nameWithIndex}.${field.name}`; + + return ( + + {children({ ...field, name: completeFieldName })} + + ); + })} + + ); + })} + ); })} @@ -379,7 +400,7 @@ const ActionsFlex = styled(Flex)<{ expanded?: boolean }>` interface ComponentProps extends Pick, - Pick { + Pick { attribute: Schema.Attribute.Component<`${string}.${string}`, boolean>; disabled?: boolean; index: number; @@ -388,10 +409,10 @@ interface ComponentProps onClickToggle: () => void; onDeleteComponent?: React.MouseEventHandler; toggleCollapses: () => void; + children: React.ReactNode; } const Component = ({ - attribute, disabled, index, isOpen, @@ -400,18 +421,13 @@ const Component = ({ name: 'id', type: 'integer', }, + children, onClickToggle, onDeleteComponent, toggleCollapses, - renderInput = InputRenderer, ...dragProps }: ComponentProps) => { const { formatMessage } = useIntl(); - const { - edit: { components }, - } = useDocLayout(); - - const { layout } = components[attribute.component]; const displayValue = useForm('RepeatableComponent', (state) => { return getIn(state.values, [...name.split('.'), mainField.name]); @@ -500,27 +516,7 @@ const Component = ({ padding={6} gap={6} > - {layout.map((row, index) => { - return ( - - {row.map(({ size, ...field }) => { - /** - * Layouts are built from schemas so they don't understand the complete - * schema tree, for components we append the parent name to the field name - * because this is the structure for the data & permissions also understand - * the nesting involved. - */ - const completeFieldName = `${name}.${field.name}`; - - return ( - - {renderInput({ ...field, name: completeFieldName })} - - ); - })} - - ); - })} + {children} diff --git a/packages/core/content-manager/admin/src/pages/EditView/components/InputRenderer.tsx b/packages/core/content-manager/admin/src/pages/EditView/components/InputRenderer.tsx index c2136c02cd..2bfe77a68f 100644 --- a/packages/core/content-manager/admin/src/pages/EditView/components/InputRenderer.tsx +++ b/packages/core/content-manager/admin/src/pages/EditView/components/InputRenderer.tsx @@ -9,6 +9,7 @@ import { useIntl } from 'react-intl'; import { useDocumentRBAC } from '../../../features/DocumentRBAC'; import { useDoc } from '../../../hooks/useDocument'; +import { useDocLayout } from '../../../hooks/useDocumentLayout'; import { useLazyComponents } from '../../../hooks/useLazyComponents'; import { BlocksInput } from './FormInputs/BlocksInput/BlocksInput'; @@ -58,6 +59,9 @@ const InputRenderer = ({ visible, hint: providedHint, ...props }: InputRendererP ); const hint = useFieldHint(providedHint, props.attribute); + const { + edit: { components }, + } = useDocLayout(); if (!visible) { return null; @@ -114,7 +118,16 @@ const InputRenderer = ({ visible, hint: providedHint, ...props }: InputRendererP case 'blocks': return ; case 'component': - return ; + return ( + + {(inputProps) => } + + ); case 'dynamiczone': return ; case 'relation': diff --git a/packages/core/content-manager/admin/src/pages/EditView/utils/data.ts b/packages/core/content-manager/admin/src/pages/EditView/utils/data.ts index 8c86291dce..5a30dd985e 100644 --- a/packages/core/content-manager/admin/src/pages/EditView/utils/data.ts +++ b/packages/core/content-manager/admin/src/pages/EditView/utils/data.ts @@ -10,6 +10,9 @@ import type { Schema, UID } from '@strapi/types'; * traverseData * -----------------------------------------------------------------------------------------------*/ +// Make only attributes required since it's the only one Content History has +type PartialSchema = Partial & Pick; + type Predicate = ( attribute: TAttribute, value: Schema.Attribute.Value @@ -35,7 +38,7 @@ const BLOCK_LIST_ATTRIBUTE_KEYS = ['__component', '__temp_key__']; */ const traverseData = (predicate: Predicate, transform: Transform) => - (schema: Schema.Schema, components: ComponentsDictionary = {}) => + (schema: PartialSchema, components: ComponentsDictionary = {}) => (data: AnyData = {}) => { const traverse = (datum: AnyData, attributes: Schema.Schema['attributes']) => { return Object.entries(datum).reduce((acc, [key, value]) => { @@ -122,7 +125,7 @@ const prepareRelations = traverseData( /** * @internal * @description Adds a `__temp_key__` to each component and dynamiczone item. This gives us - * a stable identifier regardless of it's ids etc. that we can then use for drag and drop. + * a stable identifier regardless of its ids etc. that we can then use for drag and drop. */ const prepareTempKeys = traverseData( (attribute) => @@ -150,7 +153,7 @@ const prepareTempKeys = traverseData( * @description Fields that don't exist in the schema like createdAt etc. are only on the first level (not nested), * as such we don't need to traverse the components to remove them. */ -const removeFieldsThatDontExistOnSchema = (schema: Schema.Schema) => (data: AnyData) => { +const removeFieldsThatDontExistOnSchema = (schema: PartialSchema) => (data: AnyData) => { const schemaKeys = Object.keys(schema.attributes); const dataKeys = Object.keys(data); @@ -194,7 +197,7 @@ const removeNullValues = (data: AnyData) => { * form to ensure the data is correctly prepared from their default state e.g. relations are set to an empty array. */ const transformDocument = - (schema: Schema.Schema, components: ComponentsDictionary = {}) => + (schema: PartialSchema, components: ComponentsDictionary = {}) => (document: AnyData) => { const transformations = pipe( removeFieldsThatDontExistOnSchema(schema), @@ -207,4 +210,10 @@ const transformDocument = return transformations(document); }; -export { removeProhibitedFields, prepareRelations, transformDocument }; +export { + removeProhibitedFields, + prepareRelations, + prepareTempKeys, + removeFieldsThatDontExistOnSchema, + transformDocument, +};