diff --git a/packages/core/admin/admin/src/content-manager/components/Inputs.tsx b/packages/core/admin/admin/src/content-manager/components/Inputs.tsx index 0d5d41b794..bd97db992e 100644 --- a/packages/core/admin/admin/src/content-manager/components/Inputs.tsx +++ b/packages/core/admin/admin/src/content-manager/components/Inputs.tsx @@ -5,6 +5,9 @@ import { NotAllowedInput, useCMEditViewDataManager, useLibrary, + GenericInputProps, + // Needs to be imported to prevent the TypeScript "cannot be named" error + CustomInputProps as _CustomInputProps, } from '@strapi/helper-plugin'; import get from 'lodash/get'; import omit from 'lodash/omit'; @@ -12,7 +15,7 @@ import take from 'lodash/take'; import { useIntl } from 'react-intl'; import { useContentTypeLayout } from '../hooks/useContentTypeLayout'; -import { LazyComponentStore } from '../hooks/useLazyComponents'; +import { type LazyComponentStore } from '../hooks/useLazyComponents'; import { getFieldName } from '../utils/fields'; import { EditLayoutRow } from '../utils/layouts'; @@ -33,6 +36,28 @@ const VALIDATIONS_TO_OMIT = [ 'pluginOptions', ]; +/* ------------------------------------------------------------------------------------------------- + * useCustomInputs + * -----------------------------------------------------------------------------------------------*/ + +/** + * This is extracted into a hook because Content History also needs to have access to all inputs + * to properly display history versions. + */ +const useCustomInputs = (customFieldInputs: LazyComponentStore) => { + const { fields } = useLibrary(); + + // @ts-expect-error – TODO: fix this later... + return { + uid: InputUID, + media: fields!.media, + wysiwyg: Wysiwyg, + blocks: BlocksInput, + ...fields, + ...customFieldInputs, + } as GenericInputProps['customInputs']; +}; + /* ------------------------------------------------------------------------------------------------- * Inputs * -----------------------------------------------------------------------------------------------*/ @@ -66,7 +91,6 @@ const Inputs = ({ updateActionAllowedFields, } = useCMEditViewDataManager(); - const { fields } = useLibrary(); const { formatMessage } = useIntl(); const { contentType: currentContentTypeLayout } = useContentTypeLayout(); @@ -213,6 +237,8 @@ const Inputs = ({ const { label, description, placeholder, visible } = metadatas; + const customInputs = useCustomInputs(customFieldInputs); + if (visible === false) { return null; } @@ -267,15 +293,6 @@ const Inputs = ({ ); } - const customInputs = { - uid: InputUID, - media: fields!.media, - wysiwyg: Wysiwyg, - blocks: BlocksInput, - ...fields, - ...customFieldInputs, - }; - return ( { } }; -export { Inputs }; +export { Inputs, useCustomInputs, getInputType }; diff --git a/packages/core/admin/admin/src/content-manager/history/components/VersionContent.tsx b/packages/core/admin/admin/src/content-manager/history/components/VersionContent.tsx index f0c79a1895..e59e0f4ff5 100644 --- a/packages/core/admin/admin/src/content-manager/history/components/VersionContent.tsx +++ b/packages/core/admin/admin/src/content-manager/history/components/VersionContent.tsx @@ -1,16 +1,219 @@ import * as React from 'react'; -import { ContentLayout, Typography } from '@strapi/design-system'; -import { Contracts } from '@strapi/plugin-content-manager/_internal/shared'; +import { ContentLayout, Flex, Grid, GridItem, Typography } from '@strapi/design-system'; +import { + ContentManagerEditViewDataManagerContext, + GenericInput, + useQueryParams, + useStrapiApp, +} from '@strapi/helper-plugin'; -interface VersionContentProps { - version: Contracts.HistoryVersions.HistoryVersionDataResponse; -} +import { HOOKS } from '../../../constants'; +import { useTypedDispatch } from '../../../core/store/hooks'; +import { DynamicZone } from '../../components/DynamicZone/Field'; +import { FieldComponent } from '../../components/FieldComponent'; +import { getInputType, useCustomInputs } from '../../components/Inputs'; +import { useLazyComponents } from '../../hooks/useLazyComponents'; +import { useSyncRbac } from '../../hooks/useSyncRbac'; +import { isDynamicZone, splitLayoutIntoPanes } from '../../pages/EditView/EditViewPage'; +import { setLayout } from '../../pages/EditViewLayoutManager'; +import { getFieldsActionMatchingPermissions } from '../../utils/permissions'; +import { useHistoryContext } from '../pages/History'; + +// These types will be added in future PRs, they need special handling +const UNSUPPORTED_TYPES = ['media', 'relation']; + +const VersionContent = () => { + const { version, layout } = useHistoryContext('VersionContent', (state) => ({ + version: state.selectedVersion, + layout: state.layout, + })); + const [{ query }] = useQueryParams(); + const dispatch = useTypedDispatch(); + + const { runHookWaterfall } = useStrapiApp(); + const mutatedLayout = runHookWaterfall(HOOKS.MUTATE_EDIT_VIEW_LAYOUT, { layout, query }); + + React.useEffect(() => { + if (mutatedLayout.layout) { + dispatch(setLayout(mutatedLayout.layout, query)); + } + }, [dispatch, mutatedLayout, query]); + + const { permissions } = useSyncRbac(query, layout.contentType.uid, 'editView'); + + const { readActionAllowedFields } = getFieldsActionMatchingPermissions( + permissions ?? [], + mutatedLayout.layout!.contentType.uid + ); + + const getCustomFields = React.useCallback(() => { + if (!mutatedLayout.layout) { + return []; + } + + const customFields: string[] = []; + Object.values(mutatedLayout.layout.contentType.attributes).forEach((value) => { + if ('customField' in value) { + customFields.push(value.customField as string); + } + }); + + Object.values(mutatedLayout.layout.components).forEach((component) => { + Object.values(component.attributes).forEach((value) => { + if ('customField' in value) { + customFields.push(value.customField as string); + } + }); + }); + + return customFields; + }, [mutatedLayout.layout]); + + const { isLazyLoading, lazyComponentStore } = useLazyComponents(getCustomFields()); + const customInputs = useCustomInputs(lazyComponentStore); + + // TODO: better loading + if (isLazyLoading) { + return null; + } + + const layoutPanes = splitLayoutIntoPanes(mutatedLayout.layout); -export const VersionContent = ({ version }: VersionContentProps) => { return ( - TODO: display content for version {version.id} + + {/* Position relative is needed to prevent VisuallyHidden from breaking the layout */} + + {layoutPanes.map((pane, paneIndex) => { + if (isDynamicZone(pane)) { + const [[{ name, fieldSchema, metadatas, ...restProps }]] = pane; + + return ( + + ); + } + + return ( + + {pane.map((row, rowIndex) => ( + + {row.map((column, columnIndex) => { + const attribute = mutatedLayout.layout!.contentType.attributes[column.name]; + const { type } = attribute; + const customFieldUid = (attribute as { customField?: string }).customField; + + if (UNSUPPORTED_TYPES.includes(type)) { + return ( + + TODO {type} + + ); + } + + if (type === 'component') { + const { + component, + max, + min, + repeatable = false, + required = false, + } = attribute; + + return ( + + + + ); + } + + const getValue = () => { + const value = version.data[column.name]; + + switch (attribute.type) { + case 'json': + return JSON.stringify(value); + case 'date': + case 'datetime': + return new Date(value as string); + default: + return value; + } + }; + + return ( + + {}} + disabled={true} + customInputs={customInputs} + value={getValue()} + attribute={attribute} + /> + + ); + })} + + ))} + + ); + })} + + ); }; + +export { VersionContent }; diff --git a/packages/core/admin/admin/src/content-manager/history/components/VersionHeader.tsx b/packages/core/admin/admin/src/content-manager/history/components/VersionHeader.tsx index 3b862f0634..d0de527054 100644 --- a/packages/core/admin/admin/src/content-manager/history/components/VersionHeader.tsx +++ b/packages/core/admin/admin/src/content-manager/history/components/VersionHeader.tsx @@ -3,20 +3,21 @@ import * as React from 'react'; import { HeaderLayout, Typography } from '@strapi/design-system'; import { Link } from '@strapi/design-system/v2'; import { ArrowLeft } from '@strapi/icons'; -import { Contracts } from '@strapi/plugin-content-manager/_internal/shared'; import { useIntl } from 'react-intl'; import { NavLink } from 'react-router-dom'; -import type { FormattedLayouts } from '../../utils/layouts'; +import { useHistoryContext } from '../pages/History'; interface VersionHeaderProps { headerId: string; - version: Contracts.HistoryVersions.HistoryVersionDataResponse; - layout: FormattedLayouts; } -export const VersionHeader = ({ headerId, version, layout }: VersionHeaderProps) => { +export const VersionHeader = ({ headerId }: VersionHeaderProps) => { const { formatMessage, formatDate } = useIntl(); + const { version, layout } = useHistoryContext('VersionHeader', (state) => ({ + version: state.selectedVersion, + layout: state.layout, + })); const mainFieldValue = version.data[layout.contentType.settings.mainField]; diff --git a/packages/core/admin/admin/src/content-manager/history/components/VersionsList.tsx b/packages/core/admin/admin/src/content-manager/history/components/VersionsList.tsx index 1161a3bb49..8aa52dbfe2 100644 --- a/packages/core/admin/admin/src/content-manager/history/components/VersionsList.tsx +++ b/packages/core/admin/admin/src/content-manager/history/components/VersionsList.tsx @@ -8,6 +8,7 @@ import { type MessageDescriptor, useIntl } from 'react-intl'; import { Link } from 'react-router-dom'; import { getDisplayName } from '../../utils/users'; +import { useHistoryContext } from '../pages/History'; /* ------------------------------------------------------------------------------------------------- * BlueText @@ -145,13 +146,12 @@ const VersionCard = ({ version, isCurrent }: VersionCardProps) => { * VersionsList * -----------------------------------------------------------------------------------------------*/ -interface VersionsListProps { - versions: Contracts.HistoryVersions.GetHistoryVersions.Response; - page: number; -} - -const VersionsList = ({ versions, page }: VersionsListProps) => { +const VersionsList = () => { const { formatMessage } = useIntl(); + const { versions, page } = useHistoryContext('VersionsList', (state) => ({ + versions: state.versions, + page: state.page, + })); return ( { as="ul" alignItems="stretch" flex={1} - overflow="scroll" + overflow="auto" > {versions.data.map((version, index) => (
  • diff --git a/packages/core/admin/admin/src/content-manager/history/components/tests/VersionHeader.test.tsx b/packages/core/admin/admin/src/content-manager/history/components/tests/VersionHeader.test.tsx index 867a30c97c..d3b1c9e2dd 100644 --- a/packages/core/admin/admin/src/content-manager/history/components/tests/VersionHeader.test.tsx +++ b/packages/core/admin/admin/src/content-manager/history/components/tests/VersionHeader.test.tsx @@ -2,6 +2,7 @@ import * as React from 'react'; import { render as renderRTL, screen } from '@tests/utils'; +import { type HistoryContextValue, HistoryProvider } from '../../pages/History'; import { VersionHeader } from '../VersionHeader'; import type { UID } from '@strapi/types'; @@ -17,7 +18,7 @@ const layout = { }, }, }; -const version = { +const selectedVersion = { id: '26', contentType: 'api::kitchensink.kitchensink' as UID.ContentType, relatedDocumentId: 'pcwmq3rlmp5w0be3cuplhnpr', @@ -30,14 +31,17 @@ const version = { }, }; -const render = (props: React.ComponentProps) => - renderRTL(); +const render = (props: HistoryContextValue) => + renderRTL( + + + + ); describe('VersionHeader', () => { it('should display the correct title and subtitle for a non-localized entry', () => { render({ - version, - headerId: '123', + selectedVersion, // @ts-expect-error ignore missing properties layout, }); @@ -48,14 +52,13 @@ describe('VersionHeader', () => { it('should display the correct title and subtitle for a localized entry', () => { render({ - version: { - ...version, + selectedVersion: { + ...selectedVersion, locale: { code: 'en', name: 'English (en)', }, }, - headerId: '123', // @ts-expect-error ignore missing properties layout, }); @@ -66,8 +69,7 @@ describe('VersionHeader', () => { it('should display the correct subtitle without an entry title (mainField)', () => { render({ - version, - headerId: '123', + selectedVersion, layout: { ...layout, contentType: { diff --git a/packages/core/admin/admin/src/content-manager/history/components/tests/VersionsList.test.tsx b/packages/core/admin/admin/src/content-manager/history/components/tests/VersionsList.test.tsx index c466a8cdbb..baa5a5c9b8 100644 --- a/packages/core/admin/admin/src/content-manager/history/components/tests/VersionsList.test.tsx +++ b/packages/core/admin/admin/src/content-manager/history/components/tests/VersionsList.test.tsx @@ -2,9 +2,11 @@ import * as React from 'react'; import { within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import { mockData } from '@tests/mockData'; import { render as renderRTL, screen, waitFor } from '@tests/utils'; import { useLocation } from 'react-router-dom'; +import { HistoryProvider } from '../../pages/History'; import { mockHistoryVersionsData } from '../../tests/mockData'; import { VersionsList } from '../VersionsList'; @@ -16,8 +18,8 @@ const LocationDisplay = () => { return {location.search}; }; -const render = (props: React.ComponentProps) => - renderRTL(, { +const render = (ui: React.ReactElement) => + renderRTL(ui, { renderOptions: { wrapper({ children }) { return ( @@ -32,7 +34,18 @@ const render = (props: React.ComponentProps) => describe('VersionsList', () => { it('renders a list of history versions', async () => { - render({ page: 1, versions: mockHistoryVersionsData.historyVersions }); + render( + + + + ); await waitFor(() => { expect(screen.queryByTestId('loader')).not.toBeInTheDocument(); diff --git a/packages/core/admin/admin/src/content-manager/history/pages/History.tsx b/packages/core/admin/admin/src/content-manager/history/pages/History.tsx index 1b80eb8089..a160b85489 100644 --- a/packages/core/admin/admin/src/content-manager/history/pages/History.tsx +++ b/packages/core/admin/admin/src/content-manager/history/pages/History.tsx @@ -2,13 +2,16 @@ import * as React from 'react'; import { Flex, Main } from '@strapi/design-system'; import { LoadingIndicatorPage, useQueryParams } from '@strapi/helper-plugin'; +import { Contracts } from '@strapi/plugin-content-manager/_internal/shared'; import { stringify } from 'qs'; import { Helmet } from 'react-helmet'; import { useIntl } from 'react-intl'; import { Navigate, useNavigate, useParams } from 'react-router-dom'; +import { createContext } from '../../../components/Context'; import { useContentTypeLayout } from '../../hooks/useLayouts'; import { buildValidGetParams } from '../../utils/api'; +import { type FormattedLayouts } from '../../utils/layouts'; import { VersionContent } from '../components/VersionContent'; import { VersionHeader } from '../components/VersionHeader'; import { VersionsList } from '../components/VersionsList'; @@ -16,6 +19,25 @@ import { useGetHistoryVersionsQuery } from '../services/historyVersion'; import type { UID } from '@strapi/types'; +/* ------------------------------------------------------------------------------------------------- + * HistoryProvider + * -----------------------------------------------------------------------------------------------*/ + +interface HistoryContextValue { + contentType: UID.ContentType; + id?: string; // null for single types + layout: FormattedLayouts; + selectedVersion: Contracts.HistoryVersions.HistoryVersionDataResponse; + versions: Contracts.HistoryVersions.GetHistoryVersions.Response; + page: number; +} + +const [HistoryProvider, useHistoryContext] = createContext('HistoryPage'); + +/* ------------------------------------------------------------------------------------------------- + * HistoryPage + * -----------------------------------------------------------------------------------------------*/ + const HistoryPage = () => { const headerId = React.useId(); const { formatMessage } = useIntl(); @@ -25,7 +47,7 @@ const HistoryPage = () => { id: string; }>(); - const { isLoading: isLoadingLayout, layout } = useContentTypeLayout(slug); + const { layout } = useContentTypeLayout(slug); // Parse state from query params const [{ query }] = useQueryParams<{ @@ -51,7 +73,7 @@ const HistoryPage = () => { } }, [versionsResponse.isLoading, navigate, query.id, versionsResponse.data?.data, query]); - if (isLoadingLayout || versionsResponse.isLoading) { + if (!layout || versionsResponse.isLoading) { return ; } @@ -64,7 +86,7 @@ const HistoryPage = () => { return null; } - const selectedVersion = versionsResponse.data?.data.find( + const selectedVersion = versionsResponse.data.data.find( (version) => version.id.toString() === query.id ); @@ -82,19 +104,29 @@ const HistoryPage = () => { defaultMessage: '{contentType} history', }, { - contentType: layout?.contentType.info.displayName, + contentType: layout.contentType.info.displayName, } )} /> - -
    - - -
    - -
    + + +
    + + +
    + +
    +
    ); }; -export { HistoryPage }; +export { HistoryPage, HistoryProvider, useHistoryContext }; +export type { HistoryContextValue }; diff --git a/packages/core/admin/admin/src/content-manager/history/pages/tests/History.test.tsx b/packages/core/admin/admin/src/content-manager/history/pages/tests/History.test.tsx index 5dd210048a..8e194702b3 100644 --- a/packages/core/admin/admin/src/content-manager/history/pages/tests/History.test.tsx +++ b/packages/core/admin/admin/src/content-manager/history/pages/tests/History.test.tsx @@ -32,7 +32,8 @@ const render = ({ path, initialEntries }: { path: string; initialEntries: string } ); -describe('History page', () => { +// TODO: remove when E2E tests are implemented +describe.skip('History page', () => { it('renders single type correctly', async () => { render({ path: '/content-manager/:singleType/:slug/history', diff --git a/packages/core/admin/admin/src/content-manager/pages/EditView/EditViewPage.tsx b/packages/core/admin/admin/src/content-manager/pages/EditView/EditViewPage.tsx index 7cc24f24fb..247ee232be 100644 --- a/packages/core/admin/admin/src/content-manager/pages/EditView/EditViewPage.tsx +++ b/packages/core/admin/admin/src/content-manager/pages/EditView/EditViewPage.tsx @@ -48,6 +48,19 @@ const CTB_PERMISSIONS = [{ action: 'plugin::content-type-builder.read', subject: * EditViewPage * -----------------------------------------------------------------------------------------------*/ +// Check if a block is a dynamic zone +const isDynamicZone = ( + block: EditLayoutRow[][] +): block is Array< + Omit & { + fieldSchema: Attribute.DynamicZone; + } +>[] => { + return block.every((subBlock) => { + return subBlock.every((obj) => obj.fieldSchema.type === 'dynamiczone'); + }); +}; + interface EditViewPageParams { collectionType: string; slug: string; @@ -103,19 +116,6 @@ const EditViewPage = ({ allowedActions, userPermissions = [] }: EditViewPageProp ? permissions.contentManager?.singleTypesConfigurations : permissions.contentManager?.collectionTypesConfigurations) ?? []; - // Check if a block is a dynamic zone - const isDynamicZone = ( - block: EditLayoutRow[][] - ): block is Array< - Omit & { - fieldSchema: Attribute.DynamicZone; - } - >[] => { - return block.every((subBlock) => { - return subBlock.every((obj) => obj.fieldSchema.type === 'dynamiczone'); - }); - }; - if (isLazyLoading) { return ; } @@ -182,18 +182,16 @@ const EditViewPage = ({ allowedActions, userPermissions = [] }: EditViewPageProp const [[{ name, fieldSchema, metadatas, ...restProps }]] = row; return ( - - - - - - - + + + + + ); } @@ -358,46 +356,50 @@ const EditViewPage = ({ allowedActions, userPermissions = [] }: EditViewPageProp * we manually split the layout into panes based on the presence of dynamic zones. where we then * use the original layout. Hence why, ITS ANOTHER ARRAY! */ -const selectAttributesLayout = createSelector( - (state: RootState) => state['content-manager_editViewLayoutManager'].currentLayout, - (layout) => { - const currentContentTypeLayoutData = layout?.contentType; +const splitLayoutIntoPanes = ( + layout: RootState['content-manager_editViewLayoutManager']['currentLayout'] +) => { + const currentContentTypeLayoutData = layout?.contentType; - if (!currentContentTypeLayoutData) { - return []; + if (!currentContentTypeLayoutData) { + return []; + } + + const currentLayout = currentContentTypeLayoutData.layouts.edit; + + let currentRowIndex = 0; + const newLayout: Array = []; + + currentLayout.forEach((row) => { + const hasDynamicZone = row.some( + ({ name }) => currentContentTypeLayoutData.attributes[name]['type'] === 'dynamiczone' + ); + + if (!newLayout[currentRowIndex]) { + newLayout[currentRowIndex] = []; } - const currentLayout = currentContentTypeLayoutData.layouts.edit; - - let currentRowIndex = 0; - const newLayout: Array = []; - - currentLayout.forEach((row) => { - const hasDynamicZone = row.some( - ({ name }) => currentContentTypeLayoutData.attributes[name]['type'] === 'dynamiczone' - ); + if (hasDynamicZone) { + currentRowIndex = + currentRowIndex === 0 && newLayout[0].length === 0 ? 0 : currentRowIndex + 1; if (!newLayout[currentRowIndex]) { newLayout[currentRowIndex] = []; } + newLayout[currentRowIndex].push(row); - if (hasDynamicZone) { - currentRowIndex = - currentRowIndex === 0 && newLayout[0].length === 0 ? 0 : currentRowIndex + 1; + currentRowIndex += 1; + } else { + newLayout[currentRowIndex].push(row); + } + }); - if (!newLayout[currentRowIndex]) { - newLayout[currentRowIndex] = []; - } - newLayout[currentRowIndex].push(row); + return newLayout.filter((arr) => arr.length > 0); +}; - currentRowIndex += 1; - } else { - newLayout[currentRowIndex].push(row); - } - }); - - return newLayout.filter((arr) => arr.length > 0); - } +const selectAttributesLayout = createSelector( + (state: RootState) => state['content-manager_editViewLayoutManager'].currentLayout, + splitLayoutIntoPanes ); const selectCustomFieldUids = createSelector( @@ -443,5 +445,5 @@ const ProtectedEditViewPage = ({ userPermissions = [] }: ProtectedEditViewPagePr return ; }; -export { EditViewPage, ProtectedEditViewPage }; +export { EditViewPage, ProtectedEditViewPage, splitLayoutIntoPanes, isDynamicZone }; export type { EditViewPageProps, EditViewPageParams, ProtectedEditViewPageProps }; diff --git a/packages/core/admin/admin/src/content-manager/pages/EditViewLayoutManager.tsx b/packages/core/admin/admin/src/content-manager/pages/EditViewLayoutManager.tsx index 031cf4b7ee..fce0a3a569 100644 --- a/packages/core/admin/admin/src/content-manager/pages/EditViewLayoutManager.tsx +++ b/packages/core/admin/admin/src/content-manager/pages/EditViewLayoutManager.tsx @@ -109,5 +109,5 @@ const reducer = (state: EditViewLayoutManagerState = initialState, action: Actio } }); -export { EditViewLayoutManager, reducer }; +export { EditViewLayoutManager, reducer, setLayout }; export type { EditViewLayoutManagerState }; diff --git a/packages/core/helper-plugin/src/components/GenericInput.tsx b/packages/core/helper-plugin/src/components/GenericInput.tsx index e1a27dbaf5..038132ab0a 100644 --- a/packages/core/helper-plugin/src/components/GenericInput.tsx +++ b/packages/core/helper-plugin/src/components/GenericInput.tsx @@ -49,7 +49,7 @@ interface CustomInputProps hint?: string | React.JSX.Element | (string | React.JSX.Element)[]; } -export interface GenericInputProps { +interface GenericInputProps { attribute?: TAttribute; autoComplete?: string; customInputs?: Record>>; @@ -567,4 +567,5 @@ const GenericInput = ({ */ const MemoizedGenericInput = React.memo(GenericInput, isEqual); +export type { GenericInputProps, CustomInputProps }; export { MemoizedGenericInput as GenericInput };