diff --git a/api-tests/core/content-type-builder/collection-type.test.api.js b/api-tests/core/content-type-builder/collection-type.test.api.js index 8a3f5e1377..d5ea019dd8 100644 --- a/api-tests/core/content-type-builder/collection-type.test.api.js +++ b/api-tests/core/content-type-builder/collection-type.test.api.js @@ -361,21 +361,20 @@ describe('Content Type Builder - Content types', () => { expect(res.body).toEqual({ error: { name: 'ValidationError', - message: '2 errors occurred', + message: expect.stringContaining('errors occurred'), details: { - errors: [ - { - message: - 'contentType.singularName is not in kebab case (an-example-of-kebab-case)', + errors: expect.arrayContaining([ + expect.objectContaining({ + message: expect.stringContaining('contentType.singularName is not in kebab case'), name: 'ValidationError', - path: ['contentType', 'singularName'], - }, - { - message: 'contentType.pluralName is not in kebab case (an-example-of-kebab-case)', + path: expect.arrayContaining(['contentType', 'singularName']), + }), + expect.objectContaining({ + message: expect.stringContaining('contentType.pluralName is not in kebab case'), name: 'ValidationError', - path: ['contentType', 'pluralName'], - }, - ], + path: expect.arrayContaining(['contentType', 'pluralName']), + }), + ]), }, }, }); diff --git a/api-tests/core/strapi/api/validate-body.test.api.js b/api-tests/core/strapi/api/validate-body.test.api.js new file mode 100644 index 0000000000..9b481c367d --- /dev/null +++ b/api-tests/core/strapi/api/validate-body.test.api.js @@ -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); + }); + }); +}); diff --git a/docs/docs/docs/01-core/content-manager/03-content-releases.mdx b/docs/docs/docs/01-core/content-manager/03-content-releases.mdx new file mode 100644 index 0000000000..2a0c0f15c9 --- /dev/null +++ b/docs/docs/docs/01-core/content-manager/03-content-releases.mdx @@ -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. diff --git a/docs/docs/docs/01-core/content-releases/02-frontend/00-intro.md b/docs/docs/docs/01-core/content-releases/02-frontend/00-intro.md new file mode 100644 index 0000000000..4ee8e68519 --- /dev/null +++ b/docs/docs/docs/01-core/content-releases/02-frontend/00-intro.md @@ -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). diff --git a/docs/docs/docs/01-core/content-releases/02-frontend/01-releases-page.mdx b/docs/docs/docs/01-core/content-releases/02-frontend/01-releases-page.mdx new file mode 100644 index 0000000000..306e7a0c95 --- /dev/null +++ b/docs/docs/docs/01-core/content-releases/02-frontend/01-releases-page.mdx @@ -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. diff --git a/docs/docs/docs/01-core/content-releases/02-frontend/02-release-details-page.mdx b/docs/docs/docs/01-core/content-releases/02-frontend/02-release-details-page.mdx new file mode 100644 index 0000000000..f9688d3c8e --- /dev/null +++ b/docs/docs/docs/01-core/content-releases/02-frontend/02-release-details-page.mdx @@ -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. diff --git a/docs/docs/docs/01-core/content-releases/02-frontend/_category_.json b/docs/docs/docs/01-core/content-releases/02-frontend/_category_.json new file mode 100644 index 0000000000..050c32499c --- /dev/null +++ b/docs/docs/docs/01-core/content-releases/02-frontend/_category_.json @@ -0,0 +1,5 @@ +{ + "label": "Frontend", + "collapsible": true, + "collapsed": true +} diff --git a/e2e/tests/content-releases/release-details-page.spec.ts b/e2e/tests/content-releases/release-details-page.spec.ts index a864e60bb0..745b432229 100644 --- a/e2e/tests/content-releases/release-details-page.spec.ts +++ b/e2e/tests/content-releases/release-details-page.spec.ts @@ -73,7 +73,7 @@ describeOnCondition(/*edition === 'EE'*/ false)('Release page', () => { test('A user should be able to edit and delete a release', async ({ page }) => { // Edit the release 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('button', { name: 'Save' })).toBeDisabled(); await page.getByRole('textbox', { name: 'Name' }).fill('Trent Crimm: Independent'); @@ -84,7 +84,7 @@ describeOnCondition(/*edition === 'EE'*/ false)('Release page', () => { // Delete the release 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(); // Wait for client side redirect to the releases page await page.waitForURL('/admin/plugins/content-releases'); diff --git a/packages/core/admin/admin/src/content-manager/components/BlocksInput/Blocks/Link.tsx b/packages/core/admin/admin/src/content-manager/components/BlocksInput/Blocks/Link.tsx index 80f862b4b1..4d78fc0ac2 100644 --- a/packages/core/admin/admin/src/content-manager/components/BlocksInput/Blocks/Link.tsx +++ b/packages/core/admin/admin/src/content-manager/components/BlocksInput/Blocks/Link.tsx @@ -59,7 +59,9 @@ const LinkContent = React.forwardRef( try { // 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) { setIsSaveDisabled(true); } diff --git a/packages/core/admin/server/src/services/permission/permissions-manager/sanitize.ts b/packages/core/admin/server/src/services/permission/permissions-manager/sanitize.ts index fd38a836d9..54b1184821 100644 --- a/packages/core/admin/server/src/services/permission/permissions-manager/sanitize.ts +++ b/packages/core/admin/server/src/services/permission/permissions-manager/sanitize.ts @@ -242,12 +242,7 @@ export default ({ action, ability, model }: any) => { const nonVisibleWritableAttributes = intersection(nonVisibleAttributes, writableAttributes); - return uniq([ - ...fields, - ...STATIC_FIELDS, - ...COMPONENT_FIELDS, - ...nonVisibleWritableAttributes, - ]); + return uniq([...fields, ...COMPONENT_FIELDS, ...nonVisibleWritableAttributes]); }; const getOutputFields = (fields = []) => { diff --git a/packages/core/admin/server/src/services/permission/permissions-manager/validate.ts b/packages/core/admin/server/src/services/permission/permissions-manager/validate.ts index a772cff6f6..dedb6e2fa5 100644 --- a/packages/core/admin/server/src/services/permission/permissions-manager/validate.ts +++ b/packages/core/admin/server/src/services/permission/permissions-manager/validate.ts @@ -119,7 +119,7 @@ export default ({ action, ability, model }: any) => { const wrapValidate = (createValidateFunction: any) => { // TODO // @ts-expect-error define the correct return type - const wrappedValidate = async (data, options = {}) => { + const wrappedValidate = async (data, options = {}): Promise => { if (isArray(data)) { 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); - return uniq([ - ...fields, - ...STATIC_FIELDS, - ...COMPONENT_FIELDS, - ...nonVisibleWritableAttributes, - ]); + return uniq([...fields, ...COMPONENT_FIELDS, ...nonVisibleWritableAttributes]); }; const getQueryFields = (fields = []) => { diff --git a/packages/core/content-releases/admin/src/pages/ReleaseDetailsPage.tsx b/packages/core/content-releases/admin/src/pages/ReleaseDetailsPage.tsx index ff2b610ffa..2fc0221f65 100644 --- a/packages/core/content-releases/admin/src/pages/ReleaseDetailsPage.tsx +++ b/packages/core/content-releases/admin/src/pages/ReleaseDetailsPage.tsx @@ -9,7 +9,6 @@ import { IconButton, Link, Main, - Popover, Tr, Td, Typography, @@ -19,7 +18,7 @@ import { Icon, Tooltip, } from '@strapi/design-system'; -import { LinkButton } from '@strapi/design-system/v2'; +import { LinkButton, Menu } from '@strapi/design-system/v2'; import { CheckPermissions, LoadingIndicatorPage, @@ -76,10 +75,7 @@ const ReleaseInfoWrapper = styled(Flex)` border-top: 1px solid ${({ theme }) => theme.colors.neutral150}; `; -const StyledFlex = styled(Flex)<{ disabled?: boolean }>` - align-self: stretch; - cursor: ${({ disabled }) => (disabled ? 'not-allowed' : 'pointer')}; - +const StyledMenuItem = styled(Menu.Item)<{ disabled?: boolean }>` svg path { fill: ${({ theme, disabled }) => disabled && theme.colors.neutral500}; } @@ -108,31 +104,6 @@ const TypographyMaxWidth = styled(Typography)` max-width: 300px; `; -interface PopoverButtonProps { - onClick?: (event: React.MouseEvent) => void; - disabled?: boolean; - children: React.ReactNode; -} - -const PopoverButton = ({ onClick, disabled, children }: PopoverButtonProps) => { - return ( - - {children} - - ); -}; - interface EntryValidationTextProps { action: ReleaseAction['type']; schema: Schema.ContentType; @@ -229,8 +200,6 @@ const ReleaseDetailsLayout = ({ }: ReleaseDetailsLayoutProps) => { const { formatMessage } = useIntl(); const { releaseId } = useParams<{ releaseId: string }>(); - const [isPopoverVisible, setIsPopoverVisible] = React.useState(false); - const moreButtonRef = React.useRef(null!); const { data, isLoading: isLoadingDetails, @@ -253,15 +222,6 @@ const ReleaseDetailsLayout = ({ const release = data?.data; - const handleTogglePopover = () => { - setIsPopoverVisible((prev) => !prev); - }; - - const openReleaseModal = () => { - toggleEditReleaseModal(); - handleTogglePopover(); - }; - const handlePublishRelease = (id: string) => async () => { const response = await publishRelease({ id }); @@ -297,11 +257,6 @@ const ReleaseDetailsLayout = ({ } }; - const openWarningConfirmDialog = () => { - toggleWarningSubmit(); - handleTogglePopover(); - }; - const handleRefresh = () => { dispatch(releaseApi.util.invalidateTags([{ type: 'ReleaseAction', id: 'LIST' }])); }; @@ -373,43 +328,72 @@ const ReleaseDetailsLayout = ({ primaryAction={ !release.releasedAt && ( - - - - {isPopoverVisible && ( - - - - - - {formatMessage({ - id: 'content-releases.header.actions.edit', - defaultMessage: 'Edit', - })} - - - - - - {formatMessage({ - id: 'content-releases.header.actions.delete', - defaultMessage: 'Delete', - })} - - + + {/* + TODO Fix in the DS + - as={IconButton} has TS error: Property 'icon' does not exist on type 'IntrinsicAttributes & TriggerProps & RefAttributes' + - The Icon doesn't actually show unless you hack it with some padding...and it's still a little strange + */} + } + variant="tertiary" + /> + {/* + TODO: Using Menu instead of SimpleMenu mainly because there is no positioning provided from the DS, + Refactor this once fixed in the DS + */} + + + + + + + {formatMessage({ + id: 'content-releases.header.actions.edit', + defaultMessage: 'Edit', + })} + + + + + + + + {formatMessage({ + id: 'content-releases.header.actions.delete', + defaultMessage: 'Delete', + })} + + + - - )} + +