mirror of
https://github.com/strapi/strapi.git
synced 2025-12-27 07:03:38 +00:00
feat(history): display new and unknown fields (#20039)
This commit is contained in:
parent
144ac5e55b
commit
0a031cc777
@ -1,249 +1,171 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { Form, useField, useStrapiApp } from '@strapi/admin/strapi-admin';
|
||||
import { Form } from '@strapi/admin/strapi-admin';
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
ContentLayout,
|
||||
FieldLabel,
|
||||
Divider,
|
||||
Flex,
|
||||
Grid,
|
||||
GridItem,
|
||||
Link,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from '@strapi/design-system';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import styled from 'styled-components';
|
||||
|
||||
import { COLLECTION_TYPES } from '../../constants/collections';
|
||||
import { DocumentStatus } from '../../pages/EditView/components/DocumentStatus';
|
||||
import {
|
||||
InputRenderer,
|
||||
type InputRendererProps,
|
||||
} from '../../pages/EditView/components/InputRenderer';
|
||||
import { getRelationLabel } from '../../utils/relations';
|
||||
import { useTypedSelector } from '../../modules/hooks';
|
||||
import { useHistoryContext } from '../pages/History';
|
||||
|
||||
import type { RelationsFieldProps } from '../../pages/EditView/components/FormInputs/Relations';
|
||||
import type { RelationResult } from '../../services/relations';
|
||||
import { VersionInputRenderer } from './VersionInputRenderer';
|
||||
|
||||
const StyledAlert = styled(Alert).attrs({ closeLabel: 'Close', onClose: () => {} })`
|
||||
button {
|
||||
display: none;
|
||||
}
|
||||
`;
|
||||
import type { EditFieldLayout } from '../../hooks/useDocumentLayout';
|
||||
|
||||
/* -------------------------------------------------------------------------------------------------
|
||||
* CustomRelationInput
|
||||
* FormPanel
|
||||
* -----------------------------------------------------------------------------------------------*/
|
||||
|
||||
const LinkEllipsis = styled(Link)`
|
||||
display: block;
|
||||
const FormPanel = ({ panel }: { panel: EditFieldLayout[][] }) => {
|
||||
if (panel.some((row) => row.some((field) => field.type === 'dynamiczone'))) {
|
||||
const [row] = panel;
|
||||
const [field] = row;
|
||||
|
||||
& > span {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: block;
|
||||
return (
|
||||
<Grid key={field.name} gap={4}>
|
||||
<GridItem col={12} s={12} xs={12}>
|
||||
<VersionInputRenderer {...field} />
|
||||
</GridItem>
|
||||
</Grid>
|
||||
);
|
||||
}
|
||||
`;
|
||||
|
||||
const CustomRelationInput = (props: RelationsFieldProps) => {
|
||||
const { formatMessage } = useIntl();
|
||||
const field = useField<{ results: RelationResult[]; meta: { missingCount: number } }>(props.name);
|
||||
const { results, meta } = field.value!;
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<FieldLabel>{props.label}</FieldLabel>
|
||||
{results.length === 0 ? (
|
||||
<Box marginTop={1}>
|
||||
<StyledAlert variant="default">
|
||||
{formatMessage({
|
||||
id: 'content-manager.history.content.no-relations',
|
||||
defaultMessage: 'No relations.',
|
||||
<Box
|
||||
hasRadius
|
||||
background="neutral0"
|
||||
shadow="tableShadow"
|
||||
paddingLeft={6}
|
||||
paddingRight={6}
|
||||
paddingTop={6}
|
||||
paddingBottom={6}
|
||||
borderColor="neutral150"
|
||||
>
|
||||
<Flex direction="column" alignItems="stretch" gap={6}>
|
||||
{panel.map((row, gridRowIndex) => (
|
||||
<Grid key={gridRowIndex} gap={4}>
|
||||
{row.map(({ size, ...field }) => {
|
||||
return (
|
||||
<GridItem col={size} key={field.name} s={12} xs={12}>
|
||||
<VersionInputRenderer {...field} />
|
||||
</GridItem>
|
||||
);
|
||||
})}
|
||||
</StyledAlert>
|
||||
</Box>
|
||||
) : (
|
||||
<Flex direction="column" gap={2} marginTop={1} alignItems="stretch">
|
||||
{results.map((relationData) => {
|
||||
// @ts-expect-error – targetModel does exist on the attribute. But it's not typed.
|
||||
const href = `../${COLLECTION_TYPES}/${props.attribute.targetModel}/${relationData.documentId}`;
|
||||
const label = getRelationLabel(relationData, props.mainField);
|
||||
|
||||
return (
|
||||
<Flex
|
||||
key={relationData.documentId}
|
||||
paddingTop={2}
|
||||
paddingBottom={2}
|
||||
paddingLeft={4}
|
||||
paddingRight={4}
|
||||
hasRadius
|
||||
borderColor="neutral200"
|
||||
background="neutral150"
|
||||
justifyContent="space-between"
|
||||
>
|
||||
<Box minWidth={0} paddingTop={1} paddingBottom={1} paddingRight={4}>
|
||||
<Tooltip description={label}>
|
||||
<LinkEllipsis forwardedAs={NavLink} to={href}>
|
||||
{label}
|
||||
</LinkEllipsis>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
<DocumentStatus status={relationData.status as string} />
|
||||
</Flex>
|
||||
);
|
||||
})}
|
||||
{meta.missingCount > 0 && (
|
||||
<StyledAlert
|
||||
variant="warning"
|
||||
title={formatMessage(
|
||||
{
|
||||
id: 'content-manager.history.content.missing-relations.title',
|
||||
defaultMessage:
|
||||
'{number, plural, =1 {Missing relation} other {{number} missing relations}}',
|
||||
},
|
||||
{ number: meta.missingCount }
|
||||
)}
|
||||
>
|
||||
{formatMessage(
|
||||
{
|
||||
id: 'content-manager.history.content.missing-relations.message',
|
||||
defaultMessage:
|
||||
"{number, plural, =1 {It has} other {They have}} been deleted and can't be restored.",
|
||||
},
|
||||
{ number: meta.missingCount }
|
||||
)}
|
||||
</StyledAlert>
|
||||
)}
|
||||
</Flex>
|
||||
)}
|
||||
</Grid>
|
||||
))}
|
||||
</Flex>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
/* -------------------------------------------------------------------------------------------------
|
||||
* CustomMediaInput
|
||||
* -----------------------------------------------------------------------------------------------*/
|
||||
|
||||
const CustomMediaInput = (props: InputRendererProps) => {
|
||||
const {
|
||||
value: { results, meta },
|
||||
} = useField(props.name);
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
const fields = useStrapiApp('CustomMediaInput', (state) => state.fields);
|
||||
const MediaLibrary = fields.media as React.ComponentType<
|
||||
InputRendererProps & { multiple: boolean }
|
||||
>;
|
||||
return (
|
||||
<Flex direction="column" gap={2} alignItems="stretch">
|
||||
<Form method="PUT" disabled={true} initialValues={{ [props.name]: results }}>
|
||||
<MediaLibrary {...props} disabled={true} multiple={results.length > 1} />
|
||||
</Form>
|
||||
{meta.missingCount > 0 && (
|
||||
<StyledAlert
|
||||
variant="warning"
|
||||
closeLabel="Close"
|
||||
onClose={() => {}}
|
||||
title={formatMessage(
|
||||
{
|
||||
id: 'content-manager.history.content.missing-assets.title',
|
||||
defaultMessage:
|
||||
'{number, plural, =1 {Missing asset} other {{number} missing assets}}',
|
||||
},
|
||||
{ number: meta.missingCount }
|
||||
)}
|
||||
>
|
||||
{formatMessage(
|
||||
{
|
||||
id: 'content-manager.history.content.missing-assets.message',
|
||||
defaultMessage:
|
||||
"{number, plural, =1 {It has} other {They have}} been deleted in the Media Library and can't be restored.",
|
||||
},
|
||||
{ number: meta.missingCount }
|
||||
)}
|
||||
</StyledAlert>
|
||||
)}
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
/* -------------------------------------------------------------------------------------------------
|
||||
* CustomInputRenderer
|
||||
* -----------------------------------------------------------------------------------------------*/
|
||||
|
||||
const CustomInputRenderer = (props: InputRendererProps) => {
|
||||
switch (props.type) {
|
||||
case 'media':
|
||||
return <CustomMediaInput {...props} />;
|
||||
case 'relation':
|
||||
return <CustomRelationInput {...props} />;
|
||||
default:
|
||||
return <InputRenderer {...props} />;
|
||||
}
|
||||
};
|
||||
|
||||
/* -------------------------------------------------------------------------------------------------
|
||||
* VersionContent
|
||||
* -----------------------------------------------------------------------------------------------*/
|
||||
|
||||
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 removedAttributes = version.meta.unknownAttributes.removed;
|
||||
const removedAttributesAsFields = Object.entries(removedAttributes).map(
|
||||
([attributeName, attribute]) => {
|
||||
const field = {
|
||||
attribute,
|
||||
shouldIgnoreRBAC: true,
|
||||
type: attribute.type,
|
||||
visible: true,
|
||||
disabled: true,
|
||||
label: attributeName,
|
||||
name: attributeName,
|
||||
size: fieldSizes[attribute.type].default ?? 12,
|
||||
} as UnknownField;
|
||||
|
||||
return field;
|
||||
}
|
||||
);
|
||||
const unknownFieldsLayout = removedAttributesAsFields
|
||||
.reduce<Array<UnknownField[]>>((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]);
|
||||
|
||||
return (
|
||||
<ContentLayout>
|
||||
<Form disabled={true} method="PUT" initialValues={version.data}>
|
||||
<Flex direction="column" alignItems="stretch" gap={6} position="relative">
|
||||
{layout.map((panel, index) => {
|
||||
if (panel.some((row) => row.some((field) => field.type === 'dynamiczone'))) {
|
||||
const [row] = panel;
|
||||
const [field] = row;
|
||||
|
||||
return (
|
||||
<Grid key={field.name} gap={4}>
|
||||
<GridItem col={12} s={12} xs={12}>
|
||||
<CustomInputRenderer {...field} />
|
||||
</GridItem>
|
||||
</Grid>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
key={index}
|
||||
hasRadius
|
||||
background="neutral0"
|
||||
shadow="tableShadow"
|
||||
paddingLeft={6}
|
||||
paddingRight={6}
|
||||
paddingTop={6}
|
||||
paddingBottom={6}
|
||||
borderColor="neutral150"
|
||||
>
|
||||
<Flex direction="column" alignItems="stretch" gap={6}>
|
||||
{panel.map((row, gridRowIndex) => (
|
||||
<Grid key={gridRowIndex} gap={4}>
|
||||
{row.map(({ size, ...field }) => {
|
||||
return (
|
||||
<GridItem col={size} key={field.name} s={12} xs={12}>
|
||||
<CustomInputRenderer {...field} />
|
||||
</GridItem>
|
||||
);
|
||||
})}
|
||||
</Grid>
|
||||
))}
|
||||
</Flex>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Flex>
|
||||
</Form>
|
||||
<Box paddingBottom={8}>
|
||||
<Form disabled={true} method="PUT" initialValues={version.data}>
|
||||
<Flex direction="column" alignItems="stretch" gap={6} position="relative">
|
||||
{layout.map((panel, index) => {
|
||||
return <FormPanel key={index} panel={panel} />;
|
||||
})}
|
||||
</Flex>
|
||||
</Form>
|
||||
</Box>
|
||||
{removedAttributesAsFields.length > 0 && (
|
||||
<>
|
||||
<Divider />
|
||||
<Box paddingTop={8}>
|
||||
<Flex direction="column" alignItems="flex-start" paddingBottom={6} gap={1}>
|
||||
<Typography variant="delta">
|
||||
{formatMessage({
|
||||
id: 'content-manager.history.content.unknown-fields.title',
|
||||
defaultMessage: 'Unknown fields',
|
||||
})}
|
||||
</Typography>
|
||||
<Typography variant="pi">
|
||||
{formatMessage(
|
||||
{
|
||||
id: 'content-manager.history.content.unknown-fields.message',
|
||||
defaultMessage:
|
||||
'These fields have been deleted or renamed in the Content-Type Builder. <b>These fields will not be restored.</b>',
|
||||
},
|
||||
{
|
||||
b: (chunks: React.ReactNode) => (
|
||||
<Typography variant="pi" fontWeight="bold">
|
||||
{chunks}
|
||||
</Typography>
|
||||
),
|
||||
}
|
||||
)}
|
||||
</Typography>
|
||||
</Flex>
|
||||
<Form disabled={true} method="PUT" initialValues={version.data}>
|
||||
<Flex direction="column" alignItems="stretch" gap={6} position="relative">
|
||||
{unknownFieldsLayout.map((panel, index) => {
|
||||
return <FormPanel key={index} panel={panel} />;
|
||||
})}
|
||||
</Flex>
|
||||
</Form>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</ContentLayout>
|
||||
);
|
||||
};
|
||||
|
||||
@ -0,0 +1,368 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import {
|
||||
useStrapiApp,
|
||||
useForm,
|
||||
InputRenderer as FormInputRenderer,
|
||||
useField,
|
||||
Form,
|
||||
} from '@strapi/admin/strapi-admin';
|
||||
import { Alert, Box, FieldLabel, Flex, Link, Tooltip } from '@strapi/design-system';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import styled from 'styled-components';
|
||||
|
||||
import { COLLECTION_TYPES } from '../../constants/collections';
|
||||
import { useDocumentRBAC } from '../../features/DocumentRBAC';
|
||||
import { useDoc } from '../../hooks/useDocument';
|
||||
import { useLazyComponents } from '../../hooks/useLazyComponents';
|
||||
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';
|
||||
import {
|
||||
DynamicZone,
|
||||
useDynamicZone,
|
||||
} from '../../pages/EditView/components/FormInputs/DynamicZone/Field';
|
||||
import { NotAllowedInput } from '../../pages/EditView/components/FormInputs/NotAllowed';
|
||||
import { UIDInput } from '../../pages/EditView/components/FormInputs/UID';
|
||||
import { Wysiwyg } from '../../pages/EditView/components/FormInputs/Wysiwyg/Field';
|
||||
import { useFieldHint } from '../../pages/EditView/components/InputRenderer';
|
||||
import { getRelationLabel } from '../../utils/relations';
|
||||
import { useHistoryContext } from '../pages/History';
|
||||
|
||||
import type { EditFieldLayout } from '../../hooks/useDocumentLayout';
|
||||
import type { RelationsFieldProps } from '../../pages/EditView/components/FormInputs/Relations';
|
||||
import type { RelationResult } from '../../services/relations';
|
||||
import type { Schema } from '@strapi/types';
|
||||
import type { DistributiveOmit } from 'react-redux';
|
||||
|
||||
const StyledAlert = styled(Alert).attrs({ closeLabel: 'Close', onClose: () => {} })`
|
||||
button {
|
||||
display: none;
|
||||
}
|
||||
`;
|
||||
|
||||
/* -------------------------------------------------------------------------------------------------
|
||||
* CustomRelationInput
|
||||
* -----------------------------------------------------------------------------------------------*/
|
||||
|
||||
const LinkEllipsis = styled(Link)`
|
||||
display: block;
|
||||
|
||||
& > span {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: block;
|
||||
}
|
||||
`;
|
||||
|
||||
const CustomRelationInput = (props: RelationsFieldProps) => {
|
||||
const { formatMessage } = useIntl();
|
||||
const field = useField<{ results: RelationResult[]; meta: { missingCount: number } }>(props.name);
|
||||
const { results, meta } = field.value!;
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<FieldLabel>{props.label}</FieldLabel>
|
||||
{results.length === 0 ? (
|
||||
<Box marginTop={1}>
|
||||
<StyledAlert variant="default">
|
||||
{formatMessage({
|
||||
id: 'content-manager.history.content.no-relations',
|
||||
defaultMessage: 'No relations.',
|
||||
})}
|
||||
</StyledAlert>
|
||||
</Box>
|
||||
) : (
|
||||
<Flex direction="column" gap={2} marginTop={1} alignItems="stretch">
|
||||
{results.map((relationData) => {
|
||||
// @ts-expect-error – targetModel does exist on the attribute. But it's not typed.
|
||||
const href = `../${COLLECTION_TYPES}/${props.attribute.targetModel}/${relationData.documentId}`;
|
||||
const label = getRelationLabel(relationData, props.mainField);
|
||||
|
||||
return (
|
||||
<Flex
|
||||
key={relationData.documentId}
|
||||
paddingTop={2}
|
||||
paddingBottom={2}
|
||||
paddingLeft={4}
|
||||
paddingRight={4}
|
||||
hasRadius
|
||||
borderColor="neutral200"
|
||||
background="neutral150"
|
||||
justifyContent="space-between"
|
||||
>
|
||||
<Box minWidth={0} paddingTop={1} paddingBottom={1} paddingRight={4}>
|
||||
<Tooltip description={label}>
|
||||
<LinkEllipsis forwardedAs={NavLink} to={href}>
|
||||
{label}
|
||||
</LinkEllipsis>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
<DocumentStatus status={relationData.status as string} />
|
||||
</Flex>
|
||||
);
|
||||
})}
|
||||
{meta.missingCount > 0 && (
|
||||
<StyledAlert
|
||||
variant="warning"
|
||||
title={formatMessage(
|
||||
{
|
||||
id: 'content-manager.history.content.missing-relations.title',
|
||||
defaultMessage:
|
||||
'{number, plural, =1 {Missing relation} other {{number} missing relations}}',
|
||||
},
|
||||
{ number: meta.missingCount }
|
||||
)}
|
||||
>
|
||||
{formatMessage(
|
||||
{
|
||||
id: 'content-manager.history.content.missing-relations.message',
|
||||
defaultMessage:
|
||||
"{number, plural, =1 {It has} other {They have}} been deleted and can't be restored.",
|
||||
},
|
||||
{ number: meta.missingCount }
|
||||
)}
|
||||
</StyledAlert>
|
||||
)}
|
||||
</Flex>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
/* -------------------------------------------------------------------------------------------------
|
||||
* CustomMediaInput
|
||||
* -----------------------------------------------------------------------------------------------*/
|
||||
|
||||
const CustomMediaInput = (props: VersionInputRendererProps) => {
|
||||
const {
|
||||
value: { results, meta },
|
||||
} = useField(props.name);
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
const fields = useStrapiApp('CustomMediaInput', (state) => state.fields);
|
||||
const MediaLibrary = fields.media as React.ComponentType<
|
||||
VersionInputRendererProps & { multiple: boolean }
|
||||
>;
|
||||
return (
|
||||
<Flex direction="column" gap={2} alignItems="stretch">
|
||||
<Form method="PUT" disabled={true} initialValues={{ [props.name]: results }}>
|
||||
<MediaLibrary {...props} disabled={true} multiple={results.length > 1} />
|
||||
</Form>
|
||||
{meta.missingCount > 0 && (
|
||||
<StyledAlert
|
||||
variant="warning"
|
||||
closeLabel="Close"
|
||||
onClose={() => {}}
|
||||
title={formatMessage(
|
||||
{
|
||||
id: 'content-manager.history.content.missing-assets.title',
|
||||
defaultMessage:
|
||||
'{number, plural, =1 {Missing asset} other {{number} missing assets}}',
|
||||
},
|
||||
{ number: meta.missingCount }
|
||||
)}
|
||||
>
|
||||
{formatMessage(
|
||||
{
|
||||
id: 'content-manager.history.content.missing-assets.message',
|
||||
defaultMessage:
|
||||
"{number, plural, =1 {It has} other {They have}} been deleted in the Media Library and can't be restored.",
|
||||
},
|
||||
{ number: meta.missingCount }
|
||||
)}
|
||||
</StyledAlert>
|
||||
)}
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
type VersionInputRendererProps = DistributiveOmit<EditFieldLayout, 'size'> & {
|
||||
/**
|
||||
* In the context of content history, deleted fields need to ignore RBAC
|
||||
* @default false
|
||||
*/
|
||||
shouldIgnoreRBAC?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*
|
||||
* @description An abstraction around the regular form input renderer designed specifically
|
||||
* to be used on the History page in the content-manager. It understands how to render specific
|
||||
* inputs within the context of a history version (i.e. relations, media, ignored RBAC, etc...)
|
||||
*/
|
||||
const VersionInputRenderer = ({
|
||||
visible,
|
||||
hint: providedHint,
|
||||
shouldIgnoreRBAC = false,
|
||||
...props
|
||||
}: VersionInputRendererProps) => {
|
||||
const { formatMessage } = useIntl();
|
||||
const { version } = useHistoryContext('VersionContent', (state) => ({
|
||||
version: state.selectedVersion,
|
||||
}));
|
||||
const { id } = useDoc();
|
||||
const isFormDisabled = useForm('InputRenderer', (state) => state.disabled);
|
||||
|
||||
const isInDynamicZone = useDynamicZone('isInDynamicZone', (state) => state.isInDynamicZone);
|
||||
|
||||
const canCreateFields = useDocumentRBAC('InputRenderer', (rbac) => rbac.canCreateFields);
|
||||
const canReadFields = useDocumentRBAC('InputRenderer', (rbac) => rbac.canReadFields);
|
||||
const canUpdateFields = useDocumentRBAC('InputRenderer', (rbac) => rbac.canUpdateFields);
|
||||
const canUserAction = useDocumentRBAC('InputRenderer', (rbac) => rbac.canUserAction);
|
||||
|
||||
const editableFields = id ? canUpdateFields : canCreateFields;
|
||||
const readableFields = id ? canReadFields : canCreateFields;
|
||||
/**
|
||||
* Component fields are always readable and editable,
|
||||
* however the fields within them may not be.
|
||||
*/
|
||||
const canUserReadField = canUserAction(props.name, readableFields, props.type);
|
||||
const canUserEditField = canUserAction(props.name, editableFields, props.type);
|
||||
|
||||
const fields = useStrapiApp('InputRenderer', (app) => app.fields);
|
||||
const { lazyComponentStore } = useLazyComponents(
|
||||
attributeHasCustomFieldProperty(props.attribute) ? [props.attribute.customField] : undefined
|
||||
);
|
||||
|
||||
const hint = useFieldHint(providedHint, props.attribute);
|
||||
|
||||
if (!visible) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Don't render the field if the user can't read it.
|
||||
*/
|
||||
if (!shouldIgnoreRBAC && !canUserReadField && !isInDynamicZone) {
|
||||
return <NotAllowedInput hint={hint} {...props} />;
|
||||
}
|
||||
|
||||
const fieldIsDisabled =
|
||||
(!canUserEditField && !isInDynamicZone) || props.disabled || isFormDisabled;
|
||||
|
||||
/**
|
||||
* Attributes found on the current content-type schema cannot be restored. We handle
|
||||
* this by displaying a warning alert to the user instead of the input for that field type.
|
||||
*/
|
||||
const addedAttributes = version.meta.unknownAttributes.added;
|
||||
if (Object.keys(addedAttributes).includes(props.name)) {
|
||||
return (
|
||||
<Flex direction="column" alignItems="flex-start" gap={1}>
|
||||
<FieldLabel>{props.label}</FieldLabel>
|
||||
<StyledAlert
|
||||
width="100%"
|
||||
closeLabel="Close"
|
||||
onClose={() => {}}
|
||||
variant="warning"
|
||||
title={formatMessage({
|
||||
id: 'content-manager.history.content.new-field.title',
|
||||
defaultMessage: 'New field',
|
||||
})}
|
||||
>
|
||||
{formatMessage({
|
||||
id: 'content-manager.history.content.new-field.message',
|
||||
defaultMessage:
|
||||
"This field didn't exist when this version was saved. If you restore this version, it will be empty.",
|
||||
})}
|
||||
</StyledAlert>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Because a custom field has a unique prop but the type could be confused with either
|
||||
* the useField hook or the type of the field we need to handle it separately and first.
|
||||
*/
|
||||
if (attributeHasCustomFieldProperty(props.attribute)) {
|
||||
const CustomInput = lazyComponentStore[props.attribute.customField];
|
||||
|
||||
if (CustomInput) {
|
||||
// @ts-expect-error – TODO: fix this type error in the useLazyComponents hook.
|
||||
return <CustomInput {...props} hint={hint} disabled={fieldIsDisabled} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<FormInputRenderer
|
||||
{...props}
|
||||
hint={hint}
|
||||
// @ts-expect-error – this workaround lets us display that the custom field is missing.
|
||||
type={props.attribute.customField}
|
||||
disabled={fieldIsDisabled}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Since media fields use a custom input via the upload plugin provided by the useLibrary hook,
|
||||
* we need to handle the them before other custom inputs coming from the useLibrary hook.
|
||||
*/
|
||||
if (props.type === 'media') {
|
||||
return <CustomMediaInput {...props} disabled={fieldIsDisabled} />;
|
||||
}
|
||||
/**
|
||||
* This is where we handle ONLY the fields from the `useLibrary` hook.
|
||||
*/
|
||||
const addedInputTypes = Object.keys(fields);
|
||||
if (!attributeHasCustomFieldProperty(props.attribute) && addedInputTypes.includes(props.type)) {
|
||||
const CustomInput = fields[props.type];
|
||||
// @ts-expect-error – TODO: fix this type error in the useLibrary hook.
|
||||
return <CustomInput {...props} hint={hint} disabled={fieldIsDisabled} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* These include the content-manager specific fields, failing that we fall back
|
||||
* to the more generic form input renderer.
|
||||
*/
|
||||
switch (props.type) {
|
||||
case 'blocks':
|
||||
return <BlocksInput {...props} hint={hint} type={props.type} disabled={fieldIsDisabled} />;
|
||||
case 'component':
|
||||
return <ComponentInput {...props} hint={hint} disabled={fieldIsDisabled} />;
|
||||
case 'dynamiczone':
|
||||
return <DynamicZone {...props} hint={hint} disabled={fieldIsDisabled} />;
|
||||
case 'relation':
|
||||
return <CustomRelationInput {...props} hint={hint} disabled={fieldIsDisabled} />;
|
||||
case 'richtext':
|
||||
return <Wysiwyg {...props} hint={hint} type={props.type} disabled={fieldIsDisabled} />;
|
||||
case 'uid':
|
||||
return <UIDInput {...props} hint={hint} type={props.type} disabled={fieldIsDisabled} />;
|
||||
/**
|
||||
* Enumerations are a special case because they require options.
|
||||
*/
|
||||
case 'enumeration':
|
||||
return (
|
||||
<FormInputRenderer
|
||||
{...props}
|
||||
hint={hint}
|
||||
options={props.attribute.enum.map((value) => ({ value }))}
|
||||
// @ts-expect-error – Temp workaround so we don't forget custom-fields don't work!
|
||||
type={props.customField ? 'custom-field' : props.type}
|
||||
disabled={fieldIsDisabled}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
// These props are not needed for the generic form input renderer.
|
||||
const { unique: _unique, mainField: _mainField, ...restProps } = props;
|
||||
return (
|
||||
<FormInputRenderer
|
||||
{...restProps}
|
||||
hint={hint}
|
||||
// @ts-expect-error – Temp workaround so we don't forget custom-fields don't work!
|
||||
type={props.customField ? 'custom-field' : props.type}
|
||||
disabled={fieldIsDisabled}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const attributeHasCustomFieldProperty = (
|
||||
attribute: Schema.Attribute.AnyAttribute
|
||||
): attribute is Schema.Attribute.AnyAttribute & Schema.Attribute.CustomField<string> =>
|
||||
'customField' in attribute && typeof attribute.customField === 'string';
|
||||
|
||||
export type { VersionInputRendererProps };
|
||||
export { VersionInputRenderer };
|
||||
@ -24,7 +24,6 @@ import type { Schema } from '@strapi/types';
|
||||
import type { DistributiveOmit } from 'react-redux';
|
||||
|
||||
type InputRendererProps = DistributiveOmit<EditFieldLayout, 'size'>;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*
|
||||
@ -84,15 +83,17 @@ const InputRenderer = ({ visible, hint: providedHint, ...props }: InputRendererP
|
||||
if (CustomInput) {
|
||||
// @ts-expect-error – TODO: fix this type error in the useLazyComponents hook.
|
||||
return <CustomInput {...props} hint={hint} disabled={fieldIsDisabled} />;
|
||||
} else {
|
||||
}
|
||||
|
||||
return (
|
||||
<FormInputRenderer
|
||||
{...props}
|
||||
hint={hint}
|
||||
// @ts-expect-error – this workaround lets us display that the custom field is missing.
|
||||
type={props.attribute.customField}
|
||||
disabled={fieldIsDisabled}
|
||||
/>;
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -215,4 +216,4 @@ const getMinMax = (attribute: Schema.Attribute.AnyAttribute) => {
|
||||
};
|
||||
|
||||
export type { InputRendererProps };
|
||||
export { InputRenderer };
|
||||
export { InputRenderer, useFieldHint };
|
||||
|
||||
@ -256,6 +256,10 @@
|
||||
"history.sidebar.show-newer": "Show newer versions",
|
||||
"history.sidebar.show-older": "Show older versions",
|
||||
"history.version.subtitle": "{hasLocale, select, true {{subtitle}, in {locale}} other {{subtitle}}}",
|
||||
"history.content.new-field.title": "New field",
|
||||
"history.content.new-field.message": "This field didn't exist when this version was saved. If you restore this version, it will be empty.",
|
||||
"history.content.unknown-fields.title": "Unknown fields",
|
||||
"history.content.unknown-fields.message": "These fields have been deleted or renamed in the Content-Type Builder. <b>These fields will not be restored.</b>",
|
||||
"history.content.missing-assets.title": "{number, plural, =1 {Missing asset} other {{number} missing assets}}",
|
||||
"history.content.missing-assets.message": "{number, plural, =1 {It has} other {They have}} been deleted in the Media Library and can't be restored.",
|
||||
"history.content.missing-relations.title": "{number, plural, =1 {Missing relation} other {{number} missing relations}}",
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { login } from '../../utils/login';
|
||||
import { resetDatabaseAndImportDataFromPath } from '../../scripts/dts-import';
|
||||
import { navToHeader } from '../../utils/shared';
|
||||
import { resetDatabaseAndImportDataFromPath } from '../../utils/dts-import';
|
||||
|
||||
const createAPIToken = async (page, tokenName, duration, type) => {
|
||||
await navToHeader(page, ['Settings', 'API Tokens', 'Create new API Token'], 'Create API Token');
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { resetDatabaseAndImportDataFromPath } from '../../scripts/dts-import';
|
||||
import { resetDatabaseAndImportDataFromPath } from '../../utils/dts-import';
|
||||
import { login } from '../../utils/login';
|
||||
import { findAndClose } from '../../utils/shared';
|
||||
|
||||
|
||||
@ -1,7 +1,9 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { login } from '../../utils/login';
|
||||
import { resetDatabaseAndImportDataFromPath } from '../../scripts/dts-import';
|
||||
import { resetDatabaseAndImportDataFromPath } from '../../utils/dts-import';
|
||||
import { describeOnCondition } from '../../utils/shared';
|
||||
import { resetFiles } from '../../utils/file-reset';
|
||||
import { waitForRestart } from '../../utils/restart';
|
||||
|
||||
const hasFutureFlag = process.env.STRAPI_FEATURES_FUTURE_CONTENT_HISTORY === 'true';
|
||||
|
||||
@ -9,10 +11,15 @@ describeOnCondition(hasFutureFlag)('History', () => {
|
||||
test.describe('Collection Type', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await resetDatabaseAndImportDataFromPath('with-admin.tar');
|
||||
await resetFiles();
|
||||
await page.goto('/admin');
|
||||
await login({ page });
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
await resetFiles();
|
||||
});
|
||||
|
||||
test('A user should be able create, edit, or publish/unpublish an entry, navigate to the history page, and select versions to view from a list', async ({
|
||||
page,
|
||||
}) => {
|
||||
@ -130,11 +137,82 @@ describeOnCondition(hasFutureFlag)('History', () => {
|
||||
await expect(currentVersion.getByText('Modified')).toBeVisible();
|
||||
await expect(titleInput).toHaveValue('Being from Kansas City, Missouri');
|
||||
});
|
||||
|
||||
test('A user should be able to rename (delete + create) a field in the content-type builder and see the changes as "unknown fields" in concerned history versions', async ({
|
||||
page,
|
||||
}) => {
|
||||
const CREATE_URL =
|
||||
/\/admin\/content-manager\/collection-types\/api::article.article\/create(\?.*)?/;
|
||||
const HISTORY_URL =
|
||||
/\/admin\/content-manager\/collection-types\/api::article.article\/[^/]+\/history(\?.*)?/;
|
||||
/**
|
||||
* Create an initial entry to also create an initial version
|
||||
*/
|
||||
await page.goto('/admin');
|
||||
await page.getByRole('link', { name: 'Content Manager' }).click();
|
||||
await page.getByRole('link', { name: /Create new entry/, exact: true }).click();
|
||||
await page.waitForURL(CREATE_URL);
|
||||
await page.getByRole('textbox', { name: 'title' }).fill('Being from Kansas');
|
||||
await page.getByRole('textbox', { name: 'slug' }).fill('being-from-kansas');
|
||||
await page.getByRole('button', { name: 'Save' }).click();
|
||||
|
||||
/**
|
||||
* Rename field in content-type builder
|
||||
*/
|
||||
await page.getByRole('link', { name: 'Content-Type Builder' }).click();
|
||||
|
||||
const skipTheTour = await page.getByRole('button', { name: 'Skip the tour' });
|
||||
if (skipTheTour.isVisible()) {
|
||||
skipTheTour.click();
|
||||
}
|
||||
|
||||
await page.getByRole('link', { name: 'Article' }).click();
|
||||
await page.waitForURL(
|
||||
'/admin/plugins/content-type-builder/content-types/api::article.article'
|
||||
);
|
||||
await page.getByRole('button', { name: 'Edit title' }).first().click();
|
||||
await page.getByRole('textbox', { name: 'name' }).fill('titleRename');
|
||||
await page.getByRole('button', { name: 'Finish' }).click();
|
||||
await page.getByRole('button', { name: 'Save' }).click();
|
||||
await waitForRestart(page);
|
||||
await expect(page.getByRole('cell', { name: 'titleRename', exact: true })).toBeVisible();
|
||||
|
||||
/**
|
||||
* Update the existing entry to create another version
|
||||
*/
|
||||
await page.goto('/admin');
|
||||
await page.getByRole('link', { name: 'Content Manager' }).click();
|
||||
await page.getByRole('link', { name: 'Article' }).click();
|
||||
await page.getByRole('gridcell', { name: 'being-from-kansas' }).click();
|
||||
await page.getByRole('textbox', { name: 'titleRename' }).fill('Being from Kansas City');
|
||||
await page.getByRole('button', { name: 'Save' }).click();
|
||||
|
||||
/**
|
||||
* Go to the history page
|
||||
*/
|
||||
await page.getByRole('button', { name: /more actions/i }).click();
|
||||
await page.getByRole('menuitem', { name: 'Content History' }).click();
|
||||
await page.waitForURL(HISTORY_URL);
|
||||
const versionCards = await page.getByRole('listitem', { name: 'Version card' });
|
||||
await expect(versionCards).toHaveCount(2);
|
||||
|
||||
const previousVersion = versionCards.nth(1);
|
||||
previousVersion.click();
|
||||
|
||||
// Assert the unknown field is present
|
||||
await expect(page.getByText('Unknown fields')).toBeVisible();
|
||||
await expect(page.getByRole('textbox', { name: 'title' })).toBeVisible();
|
||||
await expect(page.getByRole('textbox', { name: 'title' })).toHaveValue('Being from Kansas');
|
||||
// Assert the new field is present
|
||||
await expect(page.getByText('titleRename')).toBeVisible();
|
||||
await page.getByRole('status').getByText('New field');
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Single Type', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await resetDatabaseAndImportDataFromPath('with-admin.tar');
|
||||
await resetFiles();
|
||||
await page.goto('/admin');
|
||||
await login({ page });
|
||||
});
|
||||
@ -248,5 +326,71 @@ describeOnCondition(hasFutureFlag)('History', () => {
|
||||
await expect(titleInput).toHaveValue('Welcome to AFC Richmond!');
|
||||
await expect(currentVersion.getByText('Modified')).toBeVisible();
|
||||
});
|
||||
|
||||
test('A user should be able to rename (delete + create) a field in the content-type builder and see the changes as "unknown fields" in concerned history versions', async ({
|
||||
page,
|
||||
}) => {
|
||||
const HISTORY_URL =
|
||||
/\/admin\/content-manager\/single-types\/api::homepage.homepage\/history(\?.*)?/;
|
||||
/**
|
||||
* Create an initial entry to also create an initial version
|
||||
*/
|
||||
await page.getByRole('link', { name: 'Content Manager' }).click();
|
||||
await page.getByRole('link', { name: 'Homepage' }).click();
|
||||
await page.getByRole('textbox', { name: 'title' }).fill('Welcome to AFC Richmond');
|
||||
await page.getByRole('button', { name: 'Save' }).click();
|
||||
|
||||
/**
|
||||
* Rename field in content-type builder
|
||||
*/
|
||||
await page.getByRole('link', { name: 'Content-Type Builder' }).click();
|
||||
|
||||
const skipTheTour = await page.getByRole('button', { name: 'Skip the tour' });
|
||||
if (skipTheTour.isVisible()) {
|
||||
skipTheTour.click();
|
||||
}
|
||||
|
||||
await page.getByRole('link', { name: 'Homepage' }).click();
|
||||
await page.waitForURL(
|
||||
'/admin/plugins/content-type-builder/content-types/api::homepage.homepage'
|
||||
);
|
||||
await page.getByRole('button', { name: 'Edit title' }).first().click();
|
||||
await page.getByRole('textbox', { name: 'name' }).fill('titleRename');
|
||||
await page.getByRole('button', { name: 'Finish' }).click();
|
||||
await page.getByRole('button', { name: 'Save' }).click();
|
||||
await waitForRestart(page);
|
||||
await expect(page.getByRole('cell', { name: 'titleRename', exact: true })).toBeVisible();
|
||||
|
||||
/**
|
||||
* Update the existing entry to create another version
|
||||
*/
|
||||
await page.goto('/admin');
|
||||
await page.getByRole('link', { name: 'Content Manager' }).click();
|
||||
await page.getByRole('link', { name: 'Homepage' }).click();
|
||||
await page.getByRole('textbox', { name: 'titleRename' }).fill('Welcome to AFC Richmond!');
|
||||
await page.getByRole('button', { name: 'Save' }).click();
|
||||
|
||||
/**
|
||||
* Go to the history page
|
||||
*/
|
||||
await page.getByRole('button', { name: /more actions/i }).click();
|
||||
await page.getByRole('menuitem', { name: 'Content History' }).click();
|
||||
await page.waitForURL(HISTORY_URL);
|
||||
const versionCards = await page.getByRole('listitem', { name: 'Version card' });
|
||||
await expect(versionCards).toHaveCount(2);
|
||||
|
||||
const previousVersion = versionCards.nth(1);
|
||||
previousVersion.click();
|
||||
|
||||
// Assert the unknown field is present
|
||||
await expect(page.getByText('Unknown fields')).toBeVisible();
|
||||
await expect(page.getByRole('textbox', { name: 'title' })).toBeVisible();
|
||||
await expect(page.getByRole('textbox', { name: 'title' })).toHaveValue(
|
||||
'Welcome to AFC Richmond'
|
||||
);
|
||||
// Assert the new field is present
|
||||
await expect(page.getByText('titleRename')).toBeVisible();
|
||||
await page.getByRole('status').getByText('New field');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { login } from '../../utils/login';
|
||||
import { resetDatabaseAndImportDataFromPath } from '../../scripts/dts-import';
|
||||
import { resetDatabaseAndImportDataFromPath } from '../../utils/dts-import';
|
||||
|
||||
type Field = {
|
||||
name: string;
|
||||
|
||||
@ -40,7 +40,8 @@ export const resetFiles = async () => {
|
||||
stdio: 'inherit',
|
||||
cwd: process.env.TEST_APP_PATH,
|
||||
});
|
||||
const dryRun = await execa('git', [...gitUser, 'clean', '-fd'], {
|
||||
|
||||
await execa('git', [...gitUser, 'clean', '-fd'], {
|
||||
stdio: 'inherit',
|
||||
cwd: process.env.TEST_APP_PATH,
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user