mirror of
https://github.com/strapi/strapi.git
synced 2025-07-13 20:11:47 +00:00
Merge branch 'develop' into v5/main
This commit is contained in:
commit
0ba9e53511
@ -361,21 +361,20 @@ describe('Content Type Builder - Content types', () => {
|
|||||||
expect(res.body).toEqual({
|
expect(res.body).toEqual({
|
||||||
error: {
|
error: {
|
||||||
name: 'ValidationError',
|
name: 'ValidationError',
|
||||||
message: '2 errors occurred',
|
message: expect.stringContaining('errors occurred'),
|
||||||
details: {
|
details: {
|
||||||
errors: [
|
errors: expect.arrayContaining([
|
||||||
{
|
expect.objectContaining({
|
||||||
message:
|
message: expect.stringContaining('contentType.singularName is not in kebab case'),
|
||||||
'contentType.singularName is not in kebab case (an-example-of-kebab-case)',
|
|
||||||
name: 'ValidationError',
|
name: 'ValidationError',
|
||||||
path: ['contentType', 'singularName'],
|
path: expect.arrayContaining(['contentType', 'singularName']),
|
||||||
},
|
}),
|
||||||
{
|
expect.objectContaining({
|
||||||
message: 'contentType.pluralName is not in kebab case (an-example-of-kebab-case)',
|
message: expect.stringContaining('contentType.pluralName is not in kebab case'),
|
||||||
name: 'ValidationError',
|
name: 'ValidationError',
|
||||||
path: ['contentType', 'pluralName'],
|
path: expect.arrayContaining(['contentType', 'pluralName']),
|
||||||
},
|
}),
|
||||||
],
|
]),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
89
api-tests/core/strapi/api/validate-body.test.api.js
Normal file
89
api-tests/core/strapi/api/validate-body.test.api.js
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const { createStrapiInstance } = require('api-tests/strapi');
|
||||||
|
const { createTestBuilder } = require('api-tests/builder');
|
||||||
|
const { createContentAPIRequest } = require('api-tests/request');
|
||||||
|
|
||||||
|
const builder = createTestBuilder();
|
||||||
|
let strapi;
|
||||||
|
let rq;
|
||||||
|
let data;
|
||||||
|
|
||||||
|
const productFixtures = [
|
||||||
|
{
|
||||||
|
name: 'foo',
|
||||||
|
description: 'first product',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'bar',
|
||||||
|
description: 'second product',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const product = {
|
||||||
|
attributes: {
|
||||||
|
name: { type: 'string' },
|
||||||
|
description: { type: 'text' },
|
||||||
|
},
|
||||||
|
displayName: 'product',
|
||||||
|
singularName: 'product',
|
||||||
|
pluralName: 'products',
|
||||||
|
description: '',
|
||||||
|
collectionName: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('Validate Body', () => {
|
||||||
|
beforeAll(async () => {
|
||||||
|
await builder
|
||||||
|
.addContentType(product)
|
||||||
|
.addFixtures(product.singularName, productFixtures)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
data = builder.fixturesFor(product.singularName);
|
||||||
|
|
||||||
|
strapi = await createStrapiInstance();
|
||||||
|
rq = await createContentAPIRequest({ strapi });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await strapi.destroy();
|
||||||
|
await builder.cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Create', () => {
|
||||||
|
test('Cannot specify the ID during entity creation', async () => {
|
||||||
|
const createPayload = { data: { id: -1, name: 'baz', description: 'third product' } };
|
||||||
|
|
||||||
|
const response = await rq.post('/products', { body: createPayload });
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
|
||||||
|
const { id, attributes } = response.body.data;
|
||||||
|
|
||||||
|
expect(id).not.toBe(createPayload.data.id);
|
||||||
|
|
||||||
|
expect(attributes).toHaveProperty('name', createPayload.data.name);
|
||||||
|
expect(attributes).toHaveProperty('description', createPayload.data.description);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Update', () => {
|
||||||
|
test('ID cannot be updated, but allowed fields can', async () => {
|
||||||
|
const target = data[0];
|
||||||
|
|
||||||
|
const updatePayload = { data: { id: -1, name: 'baz' } };
|
||||||
|
|
||||||
|
const response = await rq.put(`/products/${target.id}`, {
|
||||||
|
body: updatePayload,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
|
||||||
|
const { id, attributes } = response.body.data;
|
||||||
|
|
||||||
|
expect(id).toBe(target.id);
|
||||||
|
expect(attributes).toHaveProperty('name', updatePayload.data.name);
|
||||||
|
expect(attributes).toHaveProperty('description', target.description);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
@ -0,0 +1,20 @@
|
|||||||
|
---
|
||||||
|
title: Content Releases
|
||||||
|
description: Guide for content releases in the content-manager.
|
||||||
|
tags:
|
||||||
|
- content-manager
|
||||||
|
- content-releases
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Content releases is an enterprise feature so you need a valid license.
|
||||||
|
Additionally, your user role must be granted specific Permissions, such as `plugin::content-releases.read` and `plugin::content-releases.create-action`. You can modify these permissions in the Settings section. The capability to add entries to a release within the content manager is available through the content-manager edit view.
|
||||||
|
|
||||||
|
### Edit view
|
||||||
|
|
||||||
|
If the feature is enabled with the correct license, and the user has the necessary permissions enabled, a 'Releases' section will appear on the right side of the Edit View. The 'Releases' section has an 'Add to release' button. Clicking this button opens a modal allowing you to assign the entry to a specific release and specify the desired action (Publish/Unpublish).
|
||||||
|
|
||||||
|
In the event that no releases are present, users need to create a release first, which can be done on the [Content Releases](../content-releases/00-intro.md) page.
|
||||||
|
|
||||||
|
If the user's permissions also includes the `plugin::content-releases.delete-action`, the user can access additional options by clicking the 'More' button (represented by three dots) in the releases sidebar. This will reveal the 'Remove from release' button, providing the ability to detach the entry from the selected release.
|
@ -0,0 +1,21 @@
|
|||||||
|
---
|
||||||
|
title: Introduction
|
||||||
|
tags:
|
||||||
|
- content-releases
|
||||||
|
- tech design
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
There are two pages, ReleasesPage and ReleaseDetailsPage. To access these pages a user will need a valid Strapi license with the feature enabled and at lease `plugin::content-releases.read` permissions.
|
||||||
|
|
||||||
|
Redux toolkit is used to manage content releases data (data retrieval, release creation and editing, and fetching release actions). `Formik` is used to create/edit a release and all input components are controlled components.
|
||||||
|
|
||||||
|
### License limits
|
||||||
|
|
||||||
|
Most licenses have feature-based usage limits configured through Chargebee. These limits are exposed to the frontend through [`useLicenseLimits`](/docs/core/admin/ee/hooks/use-license-limits).
|
||||||
|
If the license doesn't specify the number of maximum pending releases an hard-coded is used: max. 3 pending releases.
|
||||||
|
|
||||||
|
### Endpoints
|
||||||
|
|
||||||
|
For a list of all available endpoints please refer to the [detailed backend design documentation](/docs/core/content-releases/backend).
|
@ -0,0 +1,18 @@
|
|||||||
|
---
|
||||||
|
title: Releases page
|
||||||
|
tags:
|
||||||
|
- content-releases
|
||||||
|
- tech design
|
||||||
|
---
|
||||||
|
|
||||||
|
### Overview
|
||||||
|
|
||||||
|
The releases page provides a comprehensive display of all available content releases. Content is organized into two tabs: 'pending' (releases not yet published) and 'done' (releases already published).
|
||||||
|
|
||||||
|
#### New Release Creation:
|
||||||
|
|
||||||
|
If a user has the `plugin::content-releases.update` permissions, a 'New release' button will be visible in the header. Clicking the button opens a form requiring a name. The name of a pending release must be unique.
|
||||||
|
|
||||||
|
#### License limits
|
||||||
|
|
||||||
|
If the user reaches the limit of maximum pending releases defined on their license then the 'New release' button is disabled and a banner is displayed to inform the user.
|
@ -0,0 +1,37 @@
|
|||||||
|
---
|
||||||
|
title: Release details page
|
||||||
|
tags:
|
||||||
|
- content-releases
|
||||||
|
- tech design
|
||||||
|
---
|
||||||
|
|
||||||
|
### Overview:
|
||||||
|
|
||||||
|
The release details page allows users with appropriate permissions to do the following:
|
||||||
|
|
||||||
|
#### Group entries:
|
||||||
|
|
||||||
|
Entries in a release can be be grouped by the following options:
|
||||||
|
|
||||||
|
- Content-Types: Group all entries with the same content type together
|
||||||
|
- Locales: Group all entries with the same locale together.
|
||||||
|
- Actions: Group all entries with the same action (publish/unpublish) together.
|
||||||
|
|
||||||
|
#### Edit a release:
|
||||||
|
|
||||||
|
If the user has the `plugin::content-releases.update` permissions, a "three dot" button will appear in the header giving access to a menu with an "Edit" option.
|
||||||
|
The Edit button opens a modal, enabling users to modify the release name. To save the changes, the new name must be unique (only for pending releases), non-empty, and distinctly modified.
|
||||||
|
|
||||||
|
#### Delete a release:
|
||||||
|
|
||||||
|
If the user has the `plugin::content-releases.delete` permissions, a "three dot" button will appear in the header giving access to a menu with a "Delete" option. Selecting this option triggers a confirmation modal.
|
||||||
|
|
||||||
|
#### View Entry Status
|
||||||
|
|
||||||
|
Each entry within a release is assigned a status, indicating its readiness for actions such as Publish/Unpublish or any validation errors present. If the entry has validation errors, a user can use the entry's "three dots" menu to access the "Edit entry" link which navigates directly to the entry in the content manager.
|
||||||
|
|
||||||
|
Within the CM edit view, users can resolve any identified errors. Upon completion, clicking the "Refresh" button on the release details page updates the status of each entry to reflect any changes made.
|
||||||
|
|
||||||
|
#### Publish a release:
|
||||||
|
|
||||||
|
To execute the release of a content release, users can simply click on the "Publish" button (shown if you have the "plugin::content-releases.publish" permission). If any of the entries exhibit validation errors, the "Publish" action triggers a notification to alert users of the errors.
|
@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"label": "Frontend",
|
||||||
|
"collapsible": true,
|
||||||
|
"collapsed": true
|
||||||
|
}
|
@ -73,7 +73,7 @@ describeOnCondition(/*edition === 'EE'*/ false)('Release page', () => {
|
|||||||
test('A user should be able to edit and delete a release', async ({ page }) => {
|
test('A user should be able to edit and delete a release', async ({ page }) => {
|
||||||
// Edit the release
|
// Edit the release
|
||||||
await page.getByRole('button', { name: 'Release edit and delete menu' }).click();
|
await page.getByRole('button', { name: 'Release edit and delete menu' }).click();
|
||||||
await page.getByRole('button', { name: 'Edit' }).click();
|
await page.getByRole('menuitem', { name: 'Edit' }).click();
|
||||||
await expect(page.getByRole('dialog', { name: 'Edit release' })).toBeVisible();
|
await expect(page.getByRole('dialog', { name: 'Edit release' })).toBeVisible();
|
||||||
await expect(page.getByRole('button', { name: 'Save' })).toBeDisabled();
|
await expect(page.getByRole('button', { name: 'Save' })).toBeDisabled();
|
||||||
await page.getByRole('textbox', { name: 'Name' }).fill('Trent Crimm: Independent');
|
await page.getByRole('textbox', { name: 'Name' }).fill('Trent Crimm: Independent');
|
||||||
@ -84,7 +84,7 @@ describeOnCondition(/*edition === 'EE'*/ false)('Release page', () => {
|
|||||||
|
|
||||||
// Delete the release
|
// Delete the release
|
||||||
await page.getByRole('button', { name: 'Release edit and delete menu' }).click();
|
await page.getByRole('button', { name: 'Release edit and delete menu' }).click();
|
||||||
await page.getByRole('button', { name: 'Delete' }).click();
|
await page.getByRole('menuitem', { name: 'Delete' }).click();
|
||||||
await page.getByRole('button', { name: 'Confirm' }).click();
|
await page.getByRole('button', { name: 'Confirm' }).click();
|
||||||
// Wait for client side redirect to the releases page
|
// Wait for client side redirect to the releases page
|
||||||
await page.waitForURL('/admin/plugins/content-releases');
|
await page.waitForURL('/admin/plugins/content-releases');
|
||||||
|
@ -59,7 +59,9 @@ const LinkContent = React.forwardRef<HTMLAnchorElement, LinkContentProps>(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// eslint-disable-next-line no-new
|
// eslint-disable-next-line no-new
|
||||||
new URL(e.target.value);
|
new URL(
|
||||||
|
e.target.value?.startsWith('/') ? `https://strapi.io${e.target.value}` : e.target.value
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setIsSaveDisabled(true);
|
setIsSaveDisabled(true);
|
||||||
}
|
}
|
||||||
|
@ -242,12 +242,7 @@ export default ({ action, ability, model }: any) => {
|
|||||||
|
|
||||||
const nonVisibleWritableAttributes = intersection(nonVisibleAttributes, writableAttributes);
|
const nonVisibleWritableAttributes = intersection(nonVisibleAttributes, writableAttributes);
|
||||||
|
|
||||||
return uniq([
|
return uniq([...fields, ...COMPONENT_FIELDS, ...nonVisibleWritableAttributes]);
|
||||||
...fields,
|
|
||||||
...STATIC_FIELDS,
|
|
||||||
...COMPONENT_FIELDS,
|
|
||||||
...nonVisibleWritableAttributes,
|
|
||||||
]);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const getOutputFields = (fields = []) => {
|
const getOutputFields = (fields = []) => {
|
||||||
|
@ -119,7 +119,7 @@ export default ({ action, ability, model }: any) => {
|
|||||||
const wrapValidate = (createValidateFunction: any) => {
|
const wrapValidate = (createValidateFunction: any) => {
|
||||||
// TODO
|
// TODO
|
||||||
// @ts-expect-error define the correct return type
|
// @ts-expect-error define the correct return type
|
||||||
const wrappedValidate = async (data, options = {}) => {
|
const wrappedValidate = async (data, options = {}): Promise<unknown> => {
|
||||||
if (isArray(data)) {
|
if (isArray(data)) {
|
||||||
return Promise.all(data.map((entity: unknown) => wrappedValidate(entity, options)));
|
return Promise.all(data.map((entity: unknown) => wrappedValidate(entity, options)));
|
||||||
}
|
}
|
||||||
@ -188,12 +188,7 @@ export default ({ action, ability, model }: any) => {
|
|||||||
|
|
||||||
const nonVisibleWritableAttributes = intersection(nonVisibleAttributes, writableAttributes);
|
const nonVisibleWritableAttributes = intersection(nonVisibleAttributes, writableAttributes);
|
||||||
|
|
||||||
return uniq([
|
return uniq([...fields, ...COMPONENT_FIELDS, ...nonVisibleWritableAttributes]);
|
||||||
...fields,
|
|
||||||
...STATIC_FIELDS,
|
|
||||||
...COMPONENT_FIELDS,
|
|
||||||
...nonVisibleWritableAttributes,
|
|
||||||
]);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const getQueryFields = (fields = []) => {
|
const getQueryFields = (fields = []) => {
|
||||||
|
@ -9,7 +9,6 @@ import {
|
|||||||
IconButton,
|
IconButton,
|
||||||
Link,
|
Link,
|
||||||
Main,
|
Main,
|
||||||
Popover,
|
|
||||||
Tr,
|
Tr,
|
||||||
Td,
|
Td,
|
||||||
Typography,
|
Typography,
|
||||||
@ -19,7 +18,7 @@ import {
|
|||||||
Icon,
|
Icon,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
} from '@strapi/design-system';
|
} from '@strapi/design-system';
|
||||||
import { LinkButton } from '@strapi/design-system/v2';
|
import { LinkButton, Menu } from '@strapi/design-system/v2';
|
||||||
import {
|
import {
|
||||||
CheckPermissions,
|
CheckPermissions,
|
||||||
LoadingIndicatorPage,
|
LoadingIndicatorPage,
|
||||||
@ -76,10 +75,7 @@ const ReleaseInfoWrapper = styled(Flex)`
|
|||||||
border-top: 1px solid ${({ theme }) => theme.colors.neutral150};
|
border-top: 1px solid ${({ theme }) => theme.colors.neutral150};
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const StyledFlex = styled(Flex)<{ disabled?: boolean }>`
|
const StyledMenuItem = styled(Menu.Item)<{ disabled?: boolean }>`
|
||||||
align-self: stretch;
|
|
||||||
cursor: ${({ disabled }) => (disabled ? 'not-allowed' : 'pointer')};
|
|
||||||
|
|
||||||
svg path {
|
svg path {
|
||||||
fill: ${({ theme, disabled }) => disabled && theme.colors.neutral500};
|
fill: ${({ theme, disabled }) => disabled && theme.colors.neutral500};
|
||||||
}
|
}
|
||||||
@ -108,31 +104,6 @@ const TypographyMaxWidth = styled(Typography)`
|
|||||||
max-width: 300px;
|
max-width: 300px;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
interface PopoverButtonProps {
|
|
||||||
onClick?: (event: React.MouseEvent<HTMLElement>) => void;
|
|
||||||
disabled?: boolean;
|
|
||||||
children: React.ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
const PopoverButton = ({ onClick, disabled, children }: PopoverButtonProps) => {
|
|
||||||
return (
|
|
||||||
<StyledFlex
|
|
||||||
paddingTop={2}
|
|
||||||
paddingBottom={2}
|
|
||||||
paddingLeft={4}
|
|
||||||
paddingRight={4}
|
|
||||||
alignItems="center"
|
|
||||||
gap={2}
|
|
||||||
as="button"
|
|
||||||
hasRadius
|
|
||||||
onClick={onClick}
|
|
||||||
disabled={disabled}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</StyledFlex>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
interface EntryValidationTextProps {
|
interface EntryValidationTextProps {
|
||||||
action: ReleaseAction['type'];
|
action: ReleaseAction['type'];
|
||||||
schema: Schema.ContentType;
|
schema: Schema.ContentType;
|
||||||
@ -229,8 +200,6 @@ const ReleaseDetailsLayout = ({
|
|||||||
}: ReleaseDetailsLayoutProps) => {
|
}: ReleaseDetailsLayoutProps) => {
|
||||||
const { formatMessage } = useIntl();
|
const { formatMessage } = useIntl();
|
||||||
const { releaseId } = useParams<{ releaseId: string }>();
|
const { releaseId } = useParams<{ releaseId: string }>();
|
||||||
const [isPopoverVisible, setIsPopoverVisible] = React.useState(false);
|
|
||||||
const moreButtonRef = React.useRef<HTMLButtonElement>(null!);
|
|
||||||
const {
|
const {
|
||||||
data,
|
data,
|
||||||
isLoading: isLoadingDetails,
|
isLoading: isLoadingDetails,
|
||||||
@ -253,15 +222,6 @@ const ReleaseDetailsLayout = ({
|
|||||||
|
|
||||||
const release = data?.data;
|
const release = data?.data;
|
||||||
|
|
||||||
const handleTogglePopover = () => {
|
|
||||||
setIsPopoverVisible((prev) => !prev);
|
|
||||||
};
|
|
||||||
|
|
||||||
const openReleaseModal = () => {
|
|
||||||
toggleEditReleaseModal();
|
|
||||||
handleTogglePopover();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePublishRelease = (id: string) => async () => {
|
const handlePublishRelease = (id: string) => async () => {
|
||||||
const response = await publishRelease({ id });
|
const response = await publishRelease({ id });
|
||||||
|
|
||||||
@ -297,11 +257,6 @@ const ReleaseDetailsLayout = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const openWarningConfirmDialog = () => {
|
|
||||||
toggleWarningSubmit();
|
|
||||||
handleTogglePopover();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRefresh = () => {
|
const handleRefresh = () => {
|
||||||
dispatch(releaseApi.util.invalidateTags([{ type: 'ReleaseAction', id: 'LIST' }]));
|
dispatch(releaseApi.util.invalidateTags([{ type: 'ReleaseAction', id: 'LIST' }]));
|
||||||
};
|
};
|
||||||
@ -373,43 +328,72 @@ const ReleaseDetailsLayout = ({
|
|||||||
primaryAction={
|
primaryAction={
|
||||||
!release.releasedAt && (
|
!release.releasedAt && (
|
||||||
<Flex gap={2}>
|
<Flex gap={2}>
|
||||||
<IconButton
|
<Menu.Root>
|
||||||
label={formatMessage({
|
{/*
|
||||||
id: 'content-releases.header.actions.open-release-actions',
|
TODO Fix in the DS
|
||||||
defaultMessage: 'Release edit and delete menu',
|
- as={IconButton} has TS error: Property 'icon' does not exist on type 'IntrinsicAttributes & TriggerProps & RefAttributes<HTMLButtonElement>'
|
||||||
})}
|
- The Icon doesn't actually show unless you hack it with some padding...and it's still a little strange
|
||||||
ref={moreButtonRef}
|
*/}
|
||||||
onClick={handleTogglePopover}
|
<Menu.Trigger
|
||||||
>
|
as={IconButton}
|
||||||
<More />
|
paddingLeft={2}
|
||||||
</IconButton>
|
paddingRight={2}
|
||||||
{isPopoverVisible && (
|
aria-label={formatMessage({
|
||||||
<Popover
|
id: 'content-releases.header.actions.open-release-actions',
|
||||||
source={moreButtonRef}
|
defaultMessage: 'Release edit and delete menu',
|
||||||
placement="bottom-end"
|
})}
|
||||||
onDismiss={handleTogglePopover}
|
// @ts-expect-error See above
|
||||||
spacing={4}
|
icon={<More />}
|
||||||
minWidth="242px"
|
variant="tertiary"
|
||||||
>
|
/>
|
||||||
<Flex alignItems="center" justifyContent="center" direction="column" padding={1}>
|
{/*
|
||||||
<PopoverButton disabled={!canUpdate} onClick={openReleaseModal}>
|
TODO: Using Menu instead of SimpleMenu mainly because there is no positioning provided from the DS,
|
||||||
<PencilIcon />
|
Refactor this once fixed in the DS
|
||||||
<Typography ellipsis>
|
*/}
|
||||||
{formatMessage({
|
<Menu.Content top={1} popoverPlacement="bottom-end">
|
||||||
id: 'content-releases.header.actions.edit',
|
<Flex
|
||||||
defaultMessage: 'Edit',
|
alignItems="center"
|
||||||
})}
|
justifyContent="center"
|
||||||
</Typography>
|
direction="column"
|
||||||
</PopoverButton>
|
padding={1}
|
||||||
<PopoverButton disabled={!canDelete} onClick={openWarningConfirmDialog}>
|
width="100%"
|
||||||
<TrashIcon />
|
>
|
||||||
<Typography ellipsis textColor="danger600">
|
<StyledMenuItem disabled={!canUpdate} onSelect={toggleEditReleaseModal}>
|
||||||
{formatMessage({
|
<Flex
|
||||||
id: 'content-releases.header.actions.delete',
|
paddingTop={2}
|
||||||
defaultMessage: 'Delete',
|
paddingBottom={2}
|
||||||
})}
|
alignItems="center"
|
||||||
</Typography>
|
gap={2}
|
||||||
</PopoverButton>
|
hasRadius
|
||||||
|
width="100%"
|
||||||
|
>
|
||||||
|
<PencilIcon />
|
||||||
|
<Typography ellipsis>
|
||||||
|
{formatMessage({
|
||||||
|
id: 'content-releases.header.actions.edit',
|
||||||
|
defaultMessage: 'Edit',
|
||||||
|
})}
|
||||||
|
</Typography>
|
||||||
|
</Flex>
|
||||||
|
</StyledMenuItem>
|
||||||
|
<StyledMenuItem disabled={!canDelete} onSelect={toggleWarningSubmit}>
|
||||||
|
<Flex
|
||||||
|
paddingTop={2}
|
||||||
|
paddingBottom={2}
|
||||||
|
alignItems="center"
|
||||||
|
gap={2}
|
||||||
|
hasRadius
|
||||||
|
width="100%"
|
||||||
|
>
|
||||||
|
<TrashIcon />
|
||||||
|
<Typography ellipsis textColor="danger600">
|
||||||
|
{formatMessage({
|
||||||
|
id: 'content-releases.header.actions.delete',
|
||||||
|
defaultMessage: 'Delete',
|
||||||
|
})}
|
||||||
|
</Typography>
|
||||||
|
</Flex>
|
||||||
|
</StyledMenuItem>
|
||||||
</Flex>
|
</Flex>
|
||||||
<ReleaseInfoWrapper
|
<ReleaseInfoWrapper
|
||||||
direction="column"
|
direction="column"
|
||||||
@ -436,8 +420,8 @@ const ReleaseDetailsLayout = ({
|
|||||||
)}
|
)}
|
||||||
</Typography>
|
</Typography>
|
||||||
</ReleaseInfoWrapper>
|
</ReleaseInfoWrapper>
|
||||||
</Popover>
|
</Menu.Content>
|
||||||
)}
|
</Menu.Root>
|
||||||
<Button size="S" variant="tertiary" onClick={handleRefresh}>
|
<Button size="S" variant="tertiary" onClick={handleRefresh}>
|
||||||
{formatMessage({
|
{formatMessage({
|
||||||
id: 'content-releases.header.actions.refresh',
|
id: 'content-releases.header.actions.refresh',
|
||||||
|
@ -71,11 +71,11 @@ describe('Releases details page', () => {
|
|||||||
await user.click(moreButton);
|
await user.click(moreButton);
|
||||||
|
|
||||||
// shows the popover actions
|
// shows the popover actions
|
||||||
const editButton = screen.getByRole('button', { name: 'Edit' });
|
const editMenuItem = screen.getByRole('menuitem', { name: 'Edit' });
|
||||||
expect(editButton).toBeInTheDocument();
|
expect(editMenuItem).toBeInTheDocument();
|
||||||
|
|
||||||
const deleteButton = screen.getByRole('button', { name: 'Delete' });
|
const deleteMenuItem = screen.getByRole('menuitem', { name: 'Delete' });
|
||||||
expect(deleteButton).toBeInTheDocument();
|
expect(deleteMenuItem).toBeInTheDocument();
|
||||||
|
|
||||||
const createdByAuthor = screen.getByText(/by Admin Admin/i);
|
const createdByAuthor = screen.getByText(/by Admin Admin/i);
|
||||||
expect(createdByAuthor).toBeInTheDocument();
|
expect(createdByAuthor).toBeInTheDocument();
|
||||||
@ -123,11 +123,11 @@ describe('Releases details page', () => {
|
|||||||
await user.click(moreButton);
|
await user.click(moreButton);
|
||||||
|
|
||||||
// shows the popover actions
|
// shows the popover actions
|
||||||
const editButton = screen.getByRole('button', { name: 'Edit' });
|
const editMenuItem = screen.getByRole('menuitem', { name: 'Edit' });
|
||||||
expect(editButton).toBeDisabled();
|
expect(editMenuItem).toHaveAttribute('aria-disabled', 'true');
|
||||||
|
|
||||||
const deleteButton = screen.getByRole('button', { name: 'Delete' });
|
const deleteMenuItem = screen.getByRole('menuitem', { name: 'Delete' });
|
||||||
expect(deleteButton).toBeDisabled();
|
expect(deleteMenuItem).toHaveAttribute('aria-disabled', 'true');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders as many tables as there are in the response', async () => {
|
it('renders as many tables as there are in the response', async () => {
|
||||||
|
@ -4,6 +4,12 @@
|
|||||||
import { ACTIONS } from '../constants';
|
import { ACTIONS } from '../constants';
|
||||||
|
|
||||||
const { register } = require('../register');
|
const { register } = require('../register');
|
||||||
|
const { bootstrap } = require('../bootstrap');
|
||||||
|
const { getService } = require('../utils');
|
||||||
|
|
||||||
|
jest.mock('../utils', () => ({
|
||||||
|
getService: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
describe('register', () => {
|
describe('register', () => {
|
||||||
const strapi = {
|
const strapi = {
|
||||||
@ -54,3 +60,50 @@ describe('register', () => {
|
|||||||
expect(strapi.admin.services.permission.actionProvider.registerMany).not.toHaveBeenCalled();
|
expect(strapi.admin.services.permission.actionProvider.registerMany).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('bootstrap', () => {
|
||||||
|
const mockSyncFromDatabase = jest.fn();
|
||||||
|
|
||||||
|
getService.mockReturnValue({
|
||||||
|
syncFromDatabase: mockSyncFromDatabase,
|
||||||
|
});
|
||||||
|
|
||||||
|
const strapi = {
|
||||||
|
db: {
|
||||||
|
lifecycles: {
|
||||||
|
subscribe: jest.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ee: {
|
||||||
|
features: {
|
||||||
|
isEnabled: jest.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
features: {
|
||||||
|
future: {
|
||||||
|
isEnabled: jest.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
log: {
|
||||||
|
error: jest.fn(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should sync scheduled jobs from the database if contentReleasesScheduling flag is enabled', async () => {
|
||||||
|
strapi.ee.features.isEnabled.mockReturnValue(true);
|
||||||
|
strapi.features.future.isEnabled.mockReturnValue(true);
|
||||||
|
mockSyncFromDatabase.mockResolvedValue(new Map());
|
||||||
|
await bootstrap({ strapi });
|
||||||
|
expect(mockSyncFromDatabase).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not sync scheduled jobs from the database if contentReleasesScheduling flag is disabled', async () => {
|
||||||
|
strapi.features.future.isEnabled.mockReturnValue(false);
|
||||||
|
await bootstrap({ strapi });
|
||||||
|
expect(mockSyncFromDatabase).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
@ -2,6 +2,7 @@
|
|||||||
import type { LoadedStrapi, Entity as StrapiEntity } from '@strapi/types';
|
import type { LoadedStrapi, Entity as StrapiEntity } from '@strapi/types';
|
||||||
|
|
||||||
import { RELEASE_ACTION_MODEL_UID } from './constants';
|
import { RELEASE_ACTION_MODEL_UID } from './constants';
|
||||||
|
import { getService } from './utils';
|
||||||
|
|
||||||
export const bootstrap = async ({ strapi }: { strapi: LoadedStrapi }) => {
|
export const bootstrap = async ({ strapi }: { strapi: LoadedStrapi }) => {
|
||||||
if (strapi.ee.features.isEnabled('cms-content-releases')) {
|
if (strapi.ee.features.isEnabled('cms-content-releases')) {
|
||||||
@ -11,7 +12,7 @@ export const bootstrap = async ({ strapi }: { strapi: LoadedStrapi }) => {
|
|||||||
// @ts-expect-error TODO: lifecycles types looks like are not 100% finished
|
// @ts-expect-error TODO: lifecycles types looks like are not 100% finished
|
||||||
const { model, result } = event;
|
const { model, result } = event;
|
||||||
// @ts-expect-error TODO: lifecycles types looks like are not 100% finished
|
// @ts-expect-error TODO: lifecycles types looks like are not 100% finished
|
||||||
if (model.kind === 'collectionType' && model.options?.draftAndPublish) {
|
if (model.kind === 'collectionType') {
|
||||||
const { id } = result;
|
const { id } = result;
|
||||||
strapi.db.query(RELEASE_ACTION_MODEL_UID).deleteMany({
|
strapi.db.query(RELEASE_ACTION_MODEL_UID).deleteMany({
|
||||||
where: {
|
where: {
|
||||||
@ -28,7 +29,7 @@ export const bootstrap = async ({ strapi }: { strapi: LoadedStrapi }) => {
|
|||||||
async beforeDeleteMany(event) {
|
async beforeDeleteMany(event) {
|
||||||
const { model, params } = event;
|
const { model, params } = event;
|
||||||
// @ts-expect-error TODO: lifecycles types looks like are not 100% finished
|
// @ts-expect-error TODO: lifecycles types looks like are not 100% finished
|
||||||
if (model.kind === 'collectionType' && model.options?.draftAndPublish) {
|
if (model.kind === 'collectionType') {
|
||||||
const { where } = params;
|
const { where } = params;
|
||||||
const entriesToDelete = await strapi.db
|
const entriesToDelete = await strapi.db
|
||||||
.query(model.uid)
|
.query(model.uid)
|
||||||
@ -55,5 +56,17 @@ export const bootstrap = async ({ strapi }: { strapi: LoadedStrapi }) => {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (strapi.features.future.isEnabled('contentReleasesScheduling')) {
|
||||||
|
getService('scheduling', { strapi })
|
||||||
|
.syncFromDatabase()
|
||||||
|
.catch((err: Error) => {
|
||||||
|
strapi.log.error(
|
||||||
|
'Error while syncing scheduled jobs from the database in the content-releases plugin. This could lead to errors in the releases scheduling.'
|
||||||
|
);
|
||||||
|
|
||||||
|
throw err;
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
17
packages/core/content-releases/server/src/destroy.ts
Normal file
17
packages/core/content-releases/server/src/destroy.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import { Job } from 'node-schedule';
|
||||||
|
import { LoadedStrapi } from '@strapi/types';
|
||||||
|
|
||||||
|
import { Release } from '../../shared/contracts/releases';
|
||||||
|
import { getService } from './utils';
|
||||||
|
|
||||||
|
export const destroy = async ({ strapi }: { strapi: LoadedStrapi }) => {
|
||||||
|
if (strapi.features.future.isEnabled('contentReleasesScheduling')) {
|
||||||
|
const scheduledJobs: Map<Release['id'], Job> = getService('scheduling', {
|
||||||
|
strapi,
|
||||||
|
}).getAll();
|
||||||
|
|
||||||
|
for (const [, job] of scheduledJobs) {
|
||||||
|
job.cancel();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
@ -1,6 +1,7 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-var-requires */
|
/* eslint-disable @typescript-eslint/no-var-requires */
|
||||||
import { register } from './register';
|
import { register } from './register';
|
||||||
import { bootstrap } from './bootstrap';
|
import { bootstrap } from './bootstrap';
|
||||||
|
import { destroy } from './destroy';
|
||||||
import { contentTypes } from './content-types';
|
import { contentTypes } from './content-types';
|
||||||
import { services } from './services';
|
import { services } from './services';
|
||||||
import { controllers } from './controllers';
|
import { controllers } from './controllers';
|
||||||
@ -11,6 +12,7 @@ const getPlugin = () => {
|
|||||||
return {
|
return {
|
||||||
register,
|
register,
|
||||||
bootstrap,
|
bootstrap,
|
||||||
|
destroy,
|
||||||
contentTypes,
|
contentTypes,
|
||||||
services,
|
services,
|
||||||
controllers,
|
controllers,
|
||||||
|
@ -2,6 +2,7 @@ import { RELEASE_MODEL_UID } from '../../constants';
|
|||||||
import createReleaseService from '../release';
|
import createReleaseService from '../release';
|
||||||
|
|
||||||
const mockSchedulingSet = jest.fn();
|
const mockSchedulingSet = jest.fn();
|
||||||
|
const mockSchedulingCancel = jest.fn();
|
||||||
|
|
||||||
const baseStrapiMock = {
|
const baseStrapiMock = {
|
||||||
utils: {
|
utils: {
|
||||||
@ -17,6 +18,7 @@ const baseStrapiMock = {
|
|||||||
validateUniqueNameForPendingRelease: jest.fn(),
|
validateUniqueNameForPendingRelease: jest.fn(),
|
||||||
validateScheduledAtIsLaterThanNow: jest.fn(),
|
validateScheduledAtIsLaterThanNow: jest.fn(),
|
||||||
set: mockSchedulingSet,
|
set: mockSchedulingSet,
|
||||||
|
cancel: mockSchedulingCancel,
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
features: {
|
features: {
|
||||||
@ -41,6 +43,10 @@ const mockUser = {
|
|||||||
|
|
||||||
describe('release service', () => {
|
describe('release service', () => {
|
||||||
describe('update', () => {
|
describe('update', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
it('updates the release', async () => {
|
it('updates the release', async () => {
|
||||||
const strapiMock = {
|
const strapiMock = {
|
||||||
...baseStrapiMock,
|
...baseStrapiMock,
|
||||||
@ -100,6 +106,54 @@ describe('release service', () => {
|
|||||||
'Release already published'
|
'Release already published'
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should set scheduling if scheduledAt is present', async () => {
|
||||||
|
const scheduledDate = new Date();
|
||||||
|
|
||||||
|
const strapiMock = {
|
||||||
|
...baseStrapiMock,
|
||||||
|
entityService: {
|
||||||
|
findOne: jest.fn().mockReturnValue({ id: 1, name: 'test' }),
|
||||||
|
update: jest
|
||||||
|
.fn()
|
||||||
|
.mockReturnValue({ id: 1, name: 'Release name', scheduledAt: scheduledDate }),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const releaseService = createReleaseService({ strapi: strapiMock });
|
||||||
|
|
||||||
|
const mockReleaseArgs = {
|
||||||
|
name: 'Release name',
|
||||||
|
scheduledAt: scheduledDate,
|
||||||
|
};
|
||||||
|
|
||||||
|
const release = await releaseService.update(1, mockReleaseArgs, { user: mockUser });
|
||||||
|
|
||||||
|
expect(release).toEqual({ id: 1, name: 'Release name', scheduledAt: scheduledDate });
|
||||||
|
expect(mockSchedulingSet).toHaveBeenCalledWith(1, mockReleaseArgs.scheduledAt);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should remove scheduling if scheduledAt is null', async () => {
|
||||||
|
const strapiMock = {
|
||||||
|
...baseStrapiMock,
|
||||||
|
entityService: {
|
||||||
|
findOne: jest.fn().mockReturnValue({ id: 1, name: 'test', scheduledAt: new Date() }),
|
||||||
|
update: jest.fn().mockReturnValue({ id: 1, name: 'Release name', scheduledAt: null }),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const releaseService = createReleaseService({ strapi: strapiMock });
|
||||||
|
|
||||||
|
const mockReleaseArgs = {
|
||||||
|
name: 'Release name',
|
||||||
|
scheduledAt: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const release = await releaseService.update(1, mockReleaseArgs, { user: mockUser });
|
||||||
|
|
||||||
|
expect(release).toEqual({ id: 1, name: 'Release name', scheduledAt: null });
|
||||||
|
expect(mockSchedulingCancel).toHaveBeenCalledWith(1);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('findActions', () => {
|
describe('findActions', () => {
|
||||||
|
@ -114,4 +114,54 @@ describe('Scheduling service', () => {
|
|||||||
expect(scheduledJobs.size).toBe(0);
|
expect(scheduledJobs.size).toBe(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('getAll', () => {
|
||||||
|
it('should return all the scheduled jobs', async () => {
|
||||||
|
const mockScheduleJob = jest.fn().mockReturnValue({ cancel: jest.fn() });
|
||||||
|
// @ts-expect-error - scheduleJob is a mock
|
||||||
|
scheduleJob.mockImplementation(mockScheduleJob);
|
||||||
|
|
||||||
|
const strapiMock = {
|
||||||
|
...baseStrapiMock,
|
||||||
|
db: {
|
||||||
|
query: jest.fn(() => ({
|
||||||
|
findOne: jest.fn().mockReturnValue({ id: 1 }),
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const date = new Date();
|
||||||
|
|
||||||
|
// @ts-expect-error Ignore missing properties
|
||||||
|
const schedulingService = createSchedulingService({ strapi: strapiMock });
|
||||||
|
await schedulingService.set('1', date);
|
||||||
|
expect(schedulingService.getAll().size).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('syncFromDatabase', () => {
|
||||||
|
it('should sync the scheduled jobs from the database', async () => {
|
||||||
|
const mockScheduleJob = jest.fn().mockReturnValue({ cancel: jest.fn() });
|
||||||
|
// @ts-expect-error - scheduleJob is a mock
|
||||||
|
scheduleJob.mockImplementation(mockScheduleJob);
|
||||||
|
|
||||||
|
const strapiMock = {
|
||||||
|
...baseStrapiMock,
|
||||||
|
db: {
|
||||||
|
query: jest.fn(() => ({
|
||||||
|
findMany: jest
|
||||||
|
.fn()
|
||||||
|
.mockReturnValue([{ id: 1, scheduledAt: new Date(), releasedAt: null }]),
|
||||||
|
findOne: jest.fn().mockReturnValue({ id: 1 }),
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// @ts-expect-error Ignore missing properties
|
||||||
|
const schedulingService = createSchedulingService({ strapi: strapiMock });
|
||||||
|
const scheduledJobs = await schedulingService.syncFromDatabase();
|
||||||
|
expect(scheduledJobs.size).toBe(1);
|
||||||
|
expect(mockScheduleJob).toHaveBeenCalledWith(expect.any(Date), expect.any(Function));
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
@ -201,6 +201,16 @@ const createReleaseService = ({ strapi }: { strapi: LoadedStrapi }) => ({
|
|||||||
) {
|
) {
|
||||||
const releaseWithCreatorFields = await setCreatorFields({ user, isEdition: true })(releaseData);
|
const releaseWithCreatorFields = await setCreatorFields({ user, isEdition: true })(releaseData);
|
||||||
|
|
||||||
|
const { validateUniqueNameForPendingRelease, validateScheduledAtIsLaterThanNow } = getService(
|
||||||
|
'release-validation',
|
||||||
|
{ strapi }
|
||||||
|
);
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
validateUniqueNameForPendingRelease(releaseWithCreatorFields.name, id),
|
||||||
|
validateScheduledAtIsLaterThanNow(releaseWithCreatorFields.scheduledAt),
|
||||||
|
]);
|
||||||
|
|
||||||
const release = await strapi.entityService.findOne(RELEASE_MODEL_UID, id);
|
const release = await strapi.entityService.findOne(RELEASE_MODEL_UID, id);
|
||||||
|
|
||||||
if (!release) {
|
if (!release) {
|
||||||
@ -220,6 +230,18 @@ const createReleaseService = ({ strapi }: { strapi: LoadedStrapi }) => ({
|
|||||||
data: releaseWithCreatorFields,
|
data: releaseWithCreatorFields,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (strapi.features.future.isEnabled('contentReleasesScheduling')) {
|
||||||
|
const schedulingService = getService('scheduling', { strapi });
|
||||||
|
|
||||||
|
if (releaseData.scheduledAt) {
|
||||||
|
// set function always cancel the previous job if it exists, so we can call it directly
|
||||||
|
await schedulingService.set(id, releaseData.scheduledAt);
|
||||||
|
} else if (release.scheduledAt) {
|
||||||
|
// When user don't send a scheduledAt and we have one on the release, means that user want to unschedule it
|
||||||
|
schedulingService.cancel(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return updatedRelease;
|
return updatedRelease;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
@ -47,6 +47,32 @@ const createSchedulingService = ({ strapi }: { strapi: LoadedStrapi }) => {
|
|||||||
|
|
||||||
return scheduledJobs;
|
return scheduledJobs;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
getAll() {
|
||||||
|
return scheduledJobs;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* On bootstrap, we can use this function to make sure to sync the scheduled jobs from the database that are not yet released
|
||||||
|
* This is useful in case the server was restarted and the scheduled jobs were lost
|
||||||
|
* This also could be used to sync different Strapi instances in case of a cluster
|
||||||
|
*/
|
||||||
|
async syncFromDatabase() {
|
||||||
|
const releases = await strapi.db.query(RELEASE_MODEL_UID).findMany({
|
||||||
|
where: {
|
||||||
|
scheduledAt: {
|
||||||
|
$gte: new Date(),
|
||||||
|
},
|
||||||
|
releasedAt: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const release of releases) {
|
||||||
|
this.set(release.id, release.scheduledAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
return scheduledJobs;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import { errors } from '@strapi/utils';
|
import { errors } from '@strapi/utils';
|
||||||
import { LoadedStrapi } from '@strapi/types';
|
import { LoadedStrapi } from '@strapi/types';
|
||||||
import type { Release, CreateRelease } from '../../../shared/contracts/releases';
|
import type { Release, CreateRelease, UpdateRelease } from '../../../shared/contracts/releases';
|
||||||
import type { CreateReleaseAction } from '../../../shared/contracts/release-actions';
|
import type { CreateReleaseAction } from '../../../shared/contracts/release-actions';
|
||||||
import { RELEASE_MODEL_UID } from '../constants';
|
import { RELEASE_MODEL_UID } from '../constants';
|
||||||
|
|
||||||
@ -60,13 +60,17 @@ const createReleaseValidationService = ({ strapi }: { strapi: LoadedStrapi }) =>
|
|||||||
throw new errors.ValidationError('You have reached the maximum number of pending releases');
|
throw new errors.ValidationError('You have reached the maximum number of pending releases');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async validateUniqueNameForPendingRelease(name: CreateRelease.Request['body']['name']) {
|
async validateUniqueNameForPendingRelease(
|
||||||
|
name: CreateRelease.Request['body']['name'],
|
||||||
|
id?: UpdateRelease.Request['params']['id']
|
||||||
|
) {
|
||||||
const pendingReleases = (await strapi.entityService.findMany(RELEASE_MODEL_UID, {
|
const pendingReleases = (await strapi.entityService.findMany(RELEASE_MODEL_UID, {
|
||||||
filters: {
|
filters: {
|
||||||
releasedAt: {
|
releasedAt: {
|
||||||
$null: true,
|
$null: true,
|
||||||
},
|
},
|
||||||
name,
|
name,
|
||||||
|
...(id && { id: { $ne: id } }),
|
||||||
},
|
},
|
||||||
})) as Release[];
|
})) as Release[];
|
||||||
|
|
||||||
|
@ -120,6 +120,8 @@ export declare namespace UpdateRelease {
|
|||||||
};
|
};
|
||||||
body: {
|
body: {
|
||||||
name: string;
|
name: string;
|
||||||
|
// When editing a release, scheduledAt always need to be explicitly sended, so it can be null to unschedule it
|
||||||
|
scheduledAt?: Date | null;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -5,7 +5,7 @@ export const RELEASE_SCHEMA = yup
|
|||||||
.shape({
|
.shape({
|
||||||
name: yup.string().trim().required(),
|
name: yup.string().trim().required(),
|
||||||
// scheduledAt is a date, but we always receive strings from the client
|
// scheduledAt is a date, but we always receive strings from the client
|
||||||
scheduledAt: yup.string(),
|
scheduledAt: yup.string().nullable(),
|
||||||
})
|
})
|
||||||
.required()
|
.required()
|
||||||
.noUnknown();
|
.noUnknown();
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import { translatedErrors as errorsTrads } from '@strapi/helper-plugin';
|
import { translatedErrors as errorsTrads } from '@strapi/helper-plugin';
|
||||||
|
import { snakeCase } from 'lodash/fp';
|
||||||
import toNumber from 'lodash/toNumber';
|
import toNumber from 'lodash/toNumber';
|
||||||
import * as yup from 'yup';
|
import * as yup from 'yup';
|
||||||
|
|
||||||
@ -16,7 +17,12 @@ const alreadyUsedAttributeNames = (
|
|||||||
if (!value) {
|
if (!value) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return !usedNames.includes(value);
|
const snakeCaseKey = snakeCase(value);
|
||||||
|
|
||||||
|
return !usedNames.some((existingKey) => {
|
||||||
|
if (existingKey === value) return false; // don't compare against itself
|
||||||
|
return snakeCase(existingKey) === snakeCaseKey;
|
||||||
|
});
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@ -47,8 +53,11 @@ const isNameAllowed = (
|
|||||||
if (!value) {
|
if (!value) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
const snakeCaseKey = snakeCase(value);
|
||||||
|
|
||||||
return !reservedNames.includes(value);
|
return !reservedNames.some((existingKey) => {
|
||||||
|
return snakeCase(existingKey) === snakeCaseKey;
|
||||||
|
});
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import { translatedErrors as errorsTrads } from '@strapi/helper-plugin';
|
import { translatedErrors as errorsTrads } from '@strapi/helper-plugin';
|
||||||
|
import { snakeCase } from 'lodash/fp';
|
||||||
import * as yup from 'yup';
|
import * as yup from 'yup';
|
||||||
|
|
||||||
import { getTrad } from '../../../utils/getTrad';
|
import { getTrad } from '../../../utils/getTrad';
|
||||||
@ -25,9 +26,16 @@ export const createComponentSchema = (
|
|||||||
|
|
||||||
const name = createComponentUid(value, category);
|
const name = createComponentUid(value, category);
|
||||||
|
|
||||||
|
const snakeCaseKey = snakeCase(name);
|
||||||
|
const snakeCaseCollectionName = snakeCase(currentCollectionName);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
!usedComponentNames.includes(name) &&
|
usedComponentNames.every((reserved) => {
|
||||||
!takenCollectionNames.includes(currentCollectionName)
|
return snakeCase(reserved) !== snakeCaseKey;
|
||||||
|
}) &&
|
||||||
|
takenCollectionNames.every(
|
||||||
|
(collectionName) => snakeCase(collectionName) !== snakeCaseCollectionName
|
||||||
|
)
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@ -38,7 +46,11 @@ export const createComponentSchema = (
|
|||||||
if (!value) {
|
if (!value) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return !reservedNames.includes(value?.trim()?.toLowerCase());
|
|
||||||
|
const snakeCaseKey = snakeCase(value);
|
||||||
|
return reservedNames.every((reserved) => {
|
||||||
|
return snakeCase(reserved) !== snakeCaseKey;
|
||||||
|
});
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.required(errorsTrads.required),
|
.required(errorsTrads.required),
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import { translatedErrors as errorsTrads } from '@strapi/helper-plugin';
|
import { translatedErrors as errorsTrads } from '@strapi/helper-plugin';
|
||||||
|
import { snakeCase } from 'lodash/fp';
|
||||||
import * as yup from 'yup';
|
import * as yup from 'yup';
|
||||||
|
|
||||||
import { getTrad } from '../../../utils/getTrad';
|
import { getTrad } from '../../../utils/getTrad';
|
||||||
@ -32,7 +33,11 @@ export const createContentTypeSchema = ({
|
|||||||
|
|
||||||
const name = createUid(value);
|
const name = createUid(value);
|
||||||
|
|
||||||
return !usedContentTypeNames.includes(name);
|
const snakeCaseKey = snakeCase(name);
|
||||||
|
|
||||||
|
return !usedContentTypeNames.some((value) => {
|
||||||
|
return snakeCase(value) === snakeCaseKey;
|
||||||
|
});
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.test({
|
.test({
|
||||||
@ -42,8 +47,11 @@ export const createContentTypeSchema = ({
|
|||||||
if (!value) {
|
if (!value) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
const snakeCaseKey = snakeCase(value);
|
||||||
|
|
||||||
return !reservedModels.includes(value?.trim()?.toLowerCase());
|
return !reservedModels.some((key) => {
|
||||||
|
return snakeCase(key) === snakeCaseKey;
|
||||||
|
});
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.required(errorsTrads.required),
|
.required(errorsTrads.required),
|
||||||
@ -57,7 +65,11 @@ export const createContentTypeSchema = ({
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return !pluralNames.includes(value);
|
const snakeCaseKey = snakeCase(value);
|
||||||
|
|
||||||
|
return !pluralNames.some((key) => {
|
||||||
|
return snakeCase(key) === snakeCaseKey;
|
||||||
|
});
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.test({
|
.test({
|
||||||
@ -68,7 +80,11 @@ export const createContentTypeSchema = ({
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return !singularNames.includes(value);
|
const snakeCaseKey = snakeCase(value);
|
||||||
|
|
||||||
|
return !singularNames.some((key) => {
|
||||||
|
return snakeCase(key) === snakeCaseKey;
|
||||||
|
});
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.test({
|
.test({
|
||||||
@ -79,7 +95,7 @@ export const createContentTypeSchema = ({
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return context.parent.singularName !== value;
|
return snakeCase(context.parent.singularName) !== snakeCase(value);
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.test({
|
.test({
|
||||||
@ -90,7 +106,11 @@ export const createContentTypeSchema = ({
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return !reservedModels.includes(value?.trim()?.toLowerCase());
|
const snakeCaseKey = snakeCase(value);
|
||||||
|
|
||||||
|
return !reservedModels.some((key) => {
|
||||||
|
return snakeCase(key) === snakeCaseKey;
|
||||||
|
});
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.test({
|
.test({
|
||||||
@ -101,7 +121,11 @@ export const createContentTypeSchema = ({
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return !collectionNames.includes(value?.trim()?.toLowerCase());
|
const snakeCaseKey = snakeCase(value);
|
||||||
|
|
||||||
|
return !collectionNames.some((key) => {
|
||||||
|
return snakeCase(key) === snakeCaseKey;
|
||||||
|
});
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.required(errorsTrads.required),
|
.required(errorsTrads.required),
|
||||||
@ -115,7 +139,11 @@ export const createContentTypeSchema = ({
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return !singularNames.includes(value);
|
const snakeCaseKey = snakeCase(value);
|
||||||
|
|
||||||
|
return !singularNames.some((key) => {
|
||||||
|
return snakeCase(key) === snakeCaseKey;
|
||||||
|
});
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.test({
|
.test({
|
||||||
@ -126,7 +154,11 @@ export const createContentTypeSchema = ({
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return !pluralNames.includes(value);
|
const snakeCaseKey = snakeCase(value);
|
||||||
|
|
||||||
|
return !pluralNames.some((key) => {
|
||||||
|
return snakeCase(key) === snakeCaseKey;
|
||||||
|
});
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.test({
|
.test({
|
||||||
@ -137,7 +169,7 @@ export const createContentTypeSchema = ({
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return context.parent.pluralName !== value;
|
return snakeCase(context.parent.pluralName) !== snakeCase(value);
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.test({
|
.test({
|
||||||
@ -148,7 +180,11 @@ export const createContentTypeSchema = ({
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return !reservedModels.includes(value?.trim()?.toLowerCase());
|
const snakeCaseKey = snakeCase(value);
|
||||||
|
|
||||||
|
return !reservedModels.some((key) => {
|
||||||
|
return snakeCase(key) === snakeCaseKey;
|
||||||
|
});
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.required(errorsTrads.required),
|
.required(errorsTrads.required),
|
||||||
|
@ -15,7 +15,7 @@ describe('Content type validator', () => {
|
|||||||
builder: {
|
builder: {
|
||||||
getReservedNames() {
|
getReservedNames() {
|
||||||
return {
|
return {
|
||||||
models: [],
|
models: ['reserved-name'],
|
||||||
attributes: ['thisIsReserved'],
|
attributes: ['thisIsReserved'],
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
@ -41,7 +41,7 @@ describe('Content type validator', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Prevents use of reservedNames', () => {
|
describe('Prevents use of reservedNames in attributes', () => {
|
||||||
test('Throws when reserved names are used', async () => {
|
test('Throws when reserved names are used', async () => {
|
||||||
const data = {
|
const data = {
|
||||||
contentType: {
|
contentType: {
|
||||||
@ -76,6 +76,79 @@ describe('Content type validator', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('Uses snake_case to compare reserved name', async () => {
|
||||||
|
const data = {
|
||||||
|
contentType: {
|
||||||
|
singularName: 'test',
|
||||||
|
pluralName: 'tests',
|
||||||
|
displayName: 'Test',
|
||||||
|
attributes: {
|
||||||
|
THIS_IS_RESERVED: {
|
||||||
|
type: 'string',
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as unknown as CreateContentTypeInput;
|
||||||
|
|
||||||
|
expect.assertions(1);
|
||||||
|
|
||||||
|
await validateUpdateContentTypeInput(data).catch((err) => {
|
||||||
|
expect(err).toMatchObject({
|
||||||
|
name: 'ValidationError',
|
||||||
|
message: 'Attribute keys cannot be one of __component, __contentType, thisIsReserved',
|
||||||
|
details: {
|
||||||
|
errors: [
|
||||||
|
{
|
||||||
|
path: ['contentType', 'attributes', 'THIS_IS_RESERVED'],
|
||||||
|
message:
|
||||||
|
'Attribute keys cannot be one of __component, __contentType, thisIsReserved',
|
||||||
|
name: 'ValidationError',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Prevents use of reservedNames in models', () => {
|
||||||
|
const reservedNames = ['singularName', 'pluralName'];
|
||||||
|
|
||||||
|
test.each(reservedNames)('Throws when reserved model names are used in %s', async (name) => {
|
||||||
|
const data = {
|
||||||
|
contentType: {
|
||||||
|
singularName: name === 'singularName' ? 'reserved-name' : 'not-reserved-single',
|
||||||
|
pluralName: name === 'pluralName' ? 'reserved-name' : 'not-reserved-plural',
|
||||||
|
displayName: 'Test',
|
||||||
|
attributes: {
|
||||||
|
notReserved: {
|
||||||
|
type: 'string',
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as unknown as CreateContentTypeInput;
|
||||||
|
|
||||||
|
expect.assertions(1);
|
||||||
|
|
||||||
|
await validateUpdateContentTypeInput(data).catch((err) => {
|
||||||
|
expect(err).toMatchObject({
|
||||||
|
name: 'ValidationError',
|
||||||
|
message: `Content Type name cannot be one of reserved-name`,
|
||||||
|
details: {
|
||||||
|
errors: [
|
||||||
|
{
|
||||||
|
path: ['contentType', name],
|
||||||
|
message: `Content Type name cannot be one of reserved-name`,
|
||||||
|
name: 'ValidationError',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('validateContentTypeInput', () => {
|
describe('validateContentTypeInput', () => {
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
/* eslint-disable no-template-curly-in-string */ // yup templates need to be in this format
|
/* eslint-disable no-template-curly-in-string */ // yup templates need to be in this format
|
||||||
|
|
||||||
import { flatMap, getOr, has } from 'lodash/fp';
|
import { flatMap, getOr, has, snakeCase } from 'lodash/fp';
|
||||||
import { yup, validateYupSchema } from '@strapi/utils';
|
import { yup, validateYupSchema } from '@strapi/utils';
|
||||||
|
|
||||||
import type { Schema, UID } from '@strapi/types';
|
import type { Schema, UID } from '@strapi/types';
|
||||||
@ -139,7 +139,11 @@ const forbiddenContentTypeNameValidator = () => {
|
|||||||
name: 'forbiddenContentTypeName',
|
name: 'forbiddenContentTypeName',
|
||||||
message: `Content Type name cannot be one of ${reservedNames.join(', ')}`,
|
message: `Content Type name cannot be one of ${reservedNames.join(', ')}`,
|
||||||
test(value: unknown) {
|
test(value: unknown) {
|
||||||
return !(value && reservedNames.includes(value as string));
|
if (typeof value !== 'string') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// compare snake case to check the actual column names that will be used in the database
|
||||||
|
return reservedNames.every((reservedName) => snakeCase(reservedName) !== snakeCase(value));
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@ -157,7 +161,13 @@ const nameIsAvailable = (isEdition: boolean) => {
|
|||||||
// don't check on edition
|
// don't check on edition
|
||||||
if (isEdition) return true;
|
if (isEdition) return true;
|
||||||
|
|
||||||
return !usedNames.includes(value as string);
|
// ignore if not a string (will be caught in another validator)
|
||||||
|
if (typeof value !== 'string') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// compare snake case to check the actual column names that will be used in the database
|
||||||
|
return usedNames.every((usedName) => snakeCase(usedName) !== snakeCase(value));
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@ -165,7 +175,7 @@ const nameIsAvailable = (isEdition: boolean) => {
|
|||||||
const nameIsNotExistingCollectionName = (isEdition: boolean) => {
|
const nameIsNotExistingCollectionName = (isEdition: boolean) => {
|
||||||
const usedNames = Object.keys(strapi.contentTypes).map(
|
const usedNames = Object.keys(strapi.contentTypes).map(
|
||||||
(key) => strapi.contentTypes[key as UID.ContentType].collectionName
|
(key) => strapi.contentTypes[key as UID.ContentType].collectionName
|
||||||
);
|
) as string[];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
name: 'nameAlreadyUsed',
|
name: 'nameAlreadyUsed',
|
||||||
@ -174,7 +184,13 @@ const nameIsNotExistingCollectionName = (isEdition: boolean) => {
|
|||||||
// don't check on edition
|
// don't check on edition
|
||||||
if (isEdition) return true;
|
if (isEdition) return true;
|
||||||
|
|
||||||
return !usedNames.includes(value as string);
|
// ignore if not a string (will be caught in another validator)
|
||||||
|
if (typeof value !== 'string') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// compare snake case to check the actual column names that will be used in the database
|
||||||
|
return usedNames.every((usedName) => snakeCase(usedName) !== snakeCase(value));
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import { yup } from '@strapi/utils';
|
import { yup } from '@strapi/utils';
|
||||||
import _ from 'lodash';
|
import _ from 'lodash';
|
||||||
|
import { snakeCase } from 'lodash/fp';
|
||||||
import { modelTypes, FORBIDDEN_ATTRIBUTE_NAMES, typeKinds } from '../../services/constants';
|
import { modelTypes, FORBIDDEN_ATTRIBUTE_NAMES, typeKinds } from '../../services/constants';
|
||||||
import { getService } from '../../utils';
|
import { getService } from '../../utils';
|
||||||
import { isValidKey, isValidCollectionName } from './common';
|
import { isValidKey, isValidCollectionName } from './common';
|
||||||
@ -46,6 +46,10 @@ const createAttributesValidator = ({ types, modelType, relations }: CreateAttrib
|
|||||||
return forbiddenValidator();
|
return forbiddenValidator();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isConflictingKey(key, attributes)) {
|
||||||
|
return conflictingKeysValidator(key);
|
||||||
|
}
|
||||||
|
|
||||||
if (attribute.type === 'relation') {
|
if (attribute.type === 'relation') {
|
||||||
return getRelationValidator(attribute, relations).test(isValidKey(key));
|
return getRelationValidator(attribute, relations).test(isValidKey(key));
|
||||||
}
|
}
|
||||||
@ -63,11 +67,25 @@ const createAttributesValidator = ({ types, modelType, relations }: CreateAttrib
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isConflictingKey = (key: string, attributes: Record<string, any>) => {
|
||||||
|
const snakeCaseKey = snakeCase(key);
|
||||||
|
|
||||||
|
return Object.keys(attributes).some((existingKey) => {
|
||||||
|
if (existingKey === key) return false; // don't compare against itself
|
||||||
|
return snakeCase(existingKey) === snakeCaseKey;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const isForbiddenKey = (key: string) => {
|
const isForbiddenKey = (key: string) => {
|
||||||
return [
|
const snakeCaseKey = snakeCase(key);
|
||||||
|
const reservedNames = [
|
||||||
...FORBIDDEN_ATTRIBUTE_NAMES,
|
...FORBIDDEN_ATTRIBUTE_NAMES,
|
||||||
...getService('builder').getReservedNames().attributes,
|
...getService('builder').getReservedNames().attributes,
|
||||||
].includes(key);
|
];
|
||||||
|
|
||||||
|
return reservedNames.some((reserved) => {
|
||||||
|
return snakeCase(reserved) === snakeCaseKey;
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const forbiddenValidator = () => {
|
const forbiddenValidator = () => {
|
||||||
@ -83,6 +101,14 @@ const forbiddenValidator = () => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const conflictingKeysValidator = (key: string) => {
|
||||||
|
return yup.mixed().test({
|
||||||
|
name: 'conflictingKeys',
|
||||||
|
message: `Attribute ${key} conflicts with an existing key`,
|
||||||
|
test: () => false,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const typeOrRelationValidator = yup.object().test({
|
const typeOrRelationValidator = yup.object().test({
|
||||||
name: 'mustHaveTypeOrTarget',
|
name: 'mustHaveTypeOrTarget',
|
||||||
message: 'Attribute must have either a type or a target',
|
message: 'Attribute must have either a type or a target',
|
||||||
|
@ -1,20 +1,32 @@
|
|||||||
export const getReservedNames = () => {
|
export const getReservedNames = () => {
|
||||||
return {
|
return {
|
||||||
models: ['boolean', 'date', 'date-time', 'dateTime', 'time', 'upload'],
|
// use kebab case everywhere since singularName and pluralName are validated that way
|
||||||
|
models: [
|
||||||
|
'boolean',
|
||||||
|
'date',
|
||||||
|
'date-time',
|
||||||
|
'time',
|
||||||
|
'upload',
|
||||||
|
'then', // https://github.com/strapi/strapi/issues/15557
|
||||||
|
'rest', // https://github.com/strapi/strapi/issues/13643
|
||||||
|
],
|
||||||
|
// attributes are compared with snake_case(name), so only snake_case is needed here and camelCase + UPPER_CASE matches will still be caught
|
||||||
attributes: [
|
attributes: [
|
||||||
|
// TODO V5: these need to come from a centralized place so we don't break things accidentally in the future
|
||||||
'id',
|
'id',
|
||||||
'documentId',
|
'documentId',
|
||||||
'document_id',
|
'document_id',
|
||||||
'created_at',
|
'created_at',
|
||||||
'createdAt',
|
|
||||||
'updated_at',
|
'updated_at',
|
||||||
'updatedAt',
|
|
||||||
'created_by',
|
|
||||||
'createdBy',
|
|
||||||
'updated_by',
|
|
||||||
'updatedBy',
|
|
||||||
'published_at',
|
'published_at',
|
||||||
'publishedAt',
|
'created_by_id',
|
||||||
|
'updated_by_id',
|
||||||
|
|
||||||
|
// TODO v5: restricting 'locale' would be a breaking change in v4 but we will need it if this is not resolved: https://github.com/strapi/strapi/issues/10181
|
||||||
|
|
||||||
|
// not actually breaking but we'll leave it to avoid confusion
|
||||||
|
'created_by',
|
||||||
|
'updated_by',
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
// strapi.db.getReservedNames();
|
// strapi.db.getReservedNames();
|
||||||
|
@ -21,6 +21,16 @@ const validParagraph = [
|
|||||||
url: 'https://strapi.io',
|
url: 'https://strapi.io',
|
||||||
children: [{ type: 'text', text: 'Strapi' }],
|
children: [{ type: 'text', text: 'Strapi' }],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
type: 'link',
|
||||||
|
url: '/strapi',
|
||||||
|
children: [{ type: 'text', text: 'Strapi relative link' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'link',
|
||||||
|
url: 'mailto:info@strapi.io',
|
||||||
|
children: [{ type: 'text', text: 'Strapi Email' }],
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
@ -18,9 +18,21 @@ const textNodeValidator = yup.object().shape({
|
|||||||
code: yup.boolean(),
|
code: yup.boolean(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const checkValidLink = (link: string) => {
|
||||||
|
try {
|
||||||
|
// eslint-disable-next-line no-new
|
||||||
|
new URL(link.startsWith('/') ? `https://strapi.io${link}` : link);
|
||||||
|
} catch (error) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
const linkNodeValidator = yup.object().shape({
|
const linkNodeValidator = yup.object().shape({
|
||||||
type: yup.string().equals(['link']).required(),
|
type: yup.string().equals(['link']).required(),
|
||||||
url: yup.string().url().required(),
|
url: yup
|
||||||
|
.string()
|
||||||
|
.test('invalid-url', 'Please specify a valid link.', (value) => checkValidLink(value ?? '')),
|
||||||
children: yup.array().of(textNodeValidator).required(),
|
children: yup.array().of(textNodeValidator).required(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { CurriedFunction1 } from 'lodash';
|
import { CurriedFunction1 } from 'lodash';
|
||||||
import { isArray, cloneDeep } from 'lodash/fp';
|
import { isArray, cloneDeep, omit } from 'lodash/fp';
|
||||||
|
|
||||||
import { getNonWritableAttributes } from '../content-types';
|
import { getNonWritableAttributes } from '../content-types';
|
||||||
import { pipeAsync } from '../async';
|
import { pipeAsync } from '../async';
|
||||||
@ -34,7 +34,9 @@ const createContentAPISanitizers = () => {
|
|||||||
const nonWritableAttributes = getNonWritableAttributes(schema);
|
const nonWritableAttributes = getNonWritableAttributes(schema);
|
||||||
|
|
||||||
const transforms = [
|
const transforms = [
|
||||||
// Remove non writable attributes
|
// Remove first level ID in inputs
|
||||||
|
omit('id'),
|
||||||
|
// Remove non-writable attributes
|
||||||
traverseEntity(visitors.removeRestrictedFields(nonWritableAttributes), { schema }),
|
traverseEntity(visitors.removeRestrictedFields(nonWritableAttributes), { schema }),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
@ -1,8 +1,9 @@
|
|||||||
import { CurriedFunction1 } from 'lodash';
|
import { CurriedFunction1 } from 'lodash';
|
||||||
import { isArray } from 'lodash/fp';
|
import { isArray, isObject } from 'lodash/fp';
|
||||||
|
|
||||||
import { getNonWritableAttributes } from '../content-types';
|
import { getNonWritableAttributes } from '../content-types';
|
||||||
import { pipeAsync } from '../async';
|
import { pipeAsync } from '../async';
|
||||||
|
import { throwInvalidParam } from './utils';
|
||||||
|
|
||||||
import * as visitors from './visitors';
|
import * as visitors from './visitors';
|
||||||
import * as validators from './validators';
|
import * as validators from './validators';
|
||||||
@ -37,7 +38,12 @@ const createContentAPIValidators = () => {
|
|||||||
const nonWritableAttributes = getNonWritableAttributes(schema);
|
const nonWritableAttributes = getNonWritableAttributes(schema);
|
||||||
|
|
||||||
const transforms = [
|
const transforms = [
|
||||||
// non writable attributes
|
(data: unknown) => {
|
||||||
|
if (isObject(data) && 'id' in data) {
|
||||||
|
throwInvalidParam({ key: 'id' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// non-writable attributes
|
||||||
traverseEntity(visitors.throwRestrictedFields(nonWritableAttributes), { schema }),
|
traverseEntity(visitors.throwRestrictedFields(nonWritableAttributes), { schema }),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
@ -5,7 +5,7 @@ import { Initializer } from './components/Initializer';
|
|||||||
import { PluginIcon } from './components/PluginIcon';
|
import { PluginIcon } from './components/PluginIcon';
|
||||||
import { pluginId } from './pluginId';
|
import { pluginId } from './pluginId';
|
||||||
|
|
||||||
const name = 'Strapi Cloud';
|
const name = 'Deploy';
|
||||||
|
|
||||||
// eslint-disable-next-line import/no-default-export
|
// eslint-disable-next-line import/no-default-export
|
||||||
export default {
|
export default {
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"Plugin.name": "Strapi Cloud",
|
"Plugin.name": "Deploy",
|
||||||
"Homepage.title": "Fully-managed Cloud Hosting for your Strapi Project",
|
"Homepage.title": "Fully-managed Cloud Hosting for your Strapi Project",
|
||||||
"Homepage.subTitle": "Follow this 2 steps process to get Everything You Need to Run Strapi in Production.",
|
"Homepage.subTitle": "Follow this 2 steps process to get Everything You Need to Run Strapi in Production.",
|
||||||
"Homepage.githubBox.title.versioned": "Project pushed to GitHub",
|
"Homepage.githubBox.title.versioned": "Project pushed to GitHub",
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"Plugin.name": "Strapi Cloud",
|
"Plugin.name": "Déploiement",
|
||||||
"Homepage.title": "Hébergement cloud entièrement géré pour votre projet Strapi",
|
"Homepage.title": "Hébergement cloud entièrement géré pour votre projet Strapi",
|
||||||
"Homepage.subTitle": "Suivez ce processus en 2 étapes pour obtenir tout ce dont vous avez besoin pour exécuter Strapi en production.",
|
"Homepage.subTitle": "Suivez ce processus en 2 étapes pour obtenir tout ce dont vous avez besoin pour exécuter Strapi en production.",
|
||||||
"Homepage.githubBox.title.versioned": "Projet uploadé sur GitHub",
|
"Homepage.githubBox.title.versioned": "Projet uploadé sur GitHub",
|
||||||
|
Loading…
x
Reference in New Issue
Block a user