feat(history): display new and unknown fields (#20039)

This commit is contained in:
markkaylor 2024-04-10 14:51:10 +02:00 committed by GitHub
parent 144ac5e55b
commit 0a031cc777
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 657 additions and 217 deletions

View File

@ -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>
);
};

View File

@ -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 };

View File

@ -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 };

View File

@ -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}}",

View File

@ -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');

View File

@ -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';

View File

@ -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');
});
});
});

View File

@ -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;

View File

@ -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,
});