diff --git a/packages/core/content-releases/admin/src/components/AddReleaseDialog.tsx b/packages/core/content-releases/admin/src/components/AddReleaseDialog.tsx index b2c453441a..ee917bcbed 100644 --- a/packages/core/content-releases/admin/src/components/AddReleaseDialog.tsx +++ b/packages/core/content-releases/admin/src/components/AddReleaseDialog.tsx @@ -11,15 +11,11 @@ import { useAPIErrorHandler, useNotification } from '@strapi/helper-plugin'; import { Formik, Form } from 'formik'; import { useIntl } from 'react-intl'; import { useHistory } from 'react-router-dom'; -import * as yup from 'yup'; +import { RELEASE_SCHEMA } from '../../../shared/validation-schemas'; import { isAxiosError } from '../services/axios'; import { useCreateReleaseMutation } from '../services/release'; -const RELEASE_SCHEMA = yup.object({ - name: yup.string().required(), -}); - interface FormValues { name: string; } @@ -35,8 +31,8 @@ interface AddReleaseDialogProps { export const AddReleaseDialog = ({ handleClose }: AddReleaseDialogProps) => { const { formatMessage } = useIntl(); const toggleNotification = useNotification(); - const { push } = useHistory(); const { formatAPIError } = useAPIErrorHandler(); + const { push } = useHistory(); const [createRelease, { isLoading }] = useCreateReleaseMutation(); diff --git a/packages/core/content-releases/admin/src/components/CMReleasesContainer.tsx b/packages/core/content-releases/admin/src/components/CMReleasesContainer.tsx new file mode 100644 index 0000000000..bf6837b8dc --- /dev/null +++ b/packages/core/content-releases/admin/src/components/CMReleasesContainer.tsx @@ -0,0 +1,272 @@ +import * as React from 'react'; + +import { + Box, + Button, + FieldLabel, + Flex, + ModalBody, + ModalHeader, + ModalLayout, + SingleSelect, + SingleSelectOption, + Typography, + ModalFooter, +} from '@strapi/design-system'; +import { + CheckPermissions, + useAPIErrorHandler, + useCMEditViewDataManager, + useNotification, +} from '@strapi/helper-plugin'; +import { Plus } from '@strapi/icons'; +import { isAxiosError } from 'axios'; +import { Formik, Form } from 'formik'; +import { useIntl } from 'react-intl'; +import { useParams } from 'react-router-dom'; +import * as yup from 'yup'; + +import { CreateReleaseAction } from '../../../shared/contracts/release-actions'; +import { PERMISSIONS } from '../constants'; +import { useCreateReleaseActionMutation, useGetReleasesForEntryQuery } from '../services/release'; + +import { ReleaseActionOptions } from './ReleaseActionOptions'; + +const RELEASE_ACTION_FORM_SCHEMA = yup.object().shape({ + type: yup.string().oneOf(['publish', 'unpublish']).required(), + releaseId: yup.string().required(), +}); + +interface FormValues { + type: CreateReleaseAction.Request['body']['type']; + releaseId: CreateReleaseAction.Request['params']['releaseId']; +} + +const INITIAL_VALUES = { + type: 'publish', + releaseId: '', +} satisfies FormValues; + +interface AddActionToReleaseModalProps { + handleClose: () => void; +} + +const AddActionToReleaseModal = ({ handleClose }: AddActionToReleaseModalProps) => { + const { formatMessage } = useIntl(); + const toggleNotification = useNotification(); + const { formatAPIError } = useAPIErrorHandler(); + const params = useParams<{ id?: string }>(); + const { + allLayoutData: { contentType }, + } = useCMEditViewDataManager(); + // Get all 'pending' releases + const response = useGetReleasesForEntryQuery(); + + const releases = response.data?.data; + const [createReleaseAction, { isLoading }] = useCreateReleaseActionMutation(); + + const handleSubmit = async (values: FormValues) => { + /** + * contentType uid and entry id are not provided by the form but required to create a Release Action. + * Optimistically we expect them to always be provided via params and CMEditViewDataManager. + * In the event they are not, we should throw an error. + */ + if (!contentType?.uid || !params.id) { + toggleNotification({ + type: 'warning', + message: formatMessage({ + id: 'content-releases.content-manager.notification.entry-error', + defaultMessage: 'Failed to get entry', + }), + }); + + return; + } + + const releaseActionEntry = { + contentType: contentType.uid, + id: params.id, + }; + const response = await createReleaseAction({ + body: { type: values.type, entry: releaseActionEntry }, + params: { releaseId: values.releaseId }, + }); + + if ('data' in response) { + // Handle success + toggleNotification({ + type: 'success', + message: formatMessage({ + id: 'content-releases.content-manager-edit-view.add-to-release.notification.success', + defaultMessage: 'Entry added to release', + }), + }); + + handleClose(); + return; + } + + if ('error' in response) { + if (isAxiosError(response.error)) { + // Handle axios error + toggleNotification({ + type: 'warning', + message: formatAPIError(response.error), + }); + } else { + // Handle generic error + toggleNotification({ + type: 'warning', + message: formatMessage({ id: 'notification.error', defaultMessage: 'An error occurred' }), + }); + } + } + }; + + return ( + + + + {formatMessage({ + id: 'content-releases.content-manager-edit-view.add-to-release', + defaultMessage: 'Add to release', + })} + + + + {({ values, setFieldValue }) => { + return ( +
+ + + + setFieldValue('releaseId', value)} + value={values.releaseId} + > + {releases?.map((release) => ( + + {release.name} + + ))} + + + + {formatMessage({ + id: 'content-releases.content-manager-edit-view.add-to-release.action-type-label', + defaultMessage: 'What do you want to do with this entry?', + })} + + + setFieldValue('type', e.target.value)} + /> + + + + + {formatMessage({ + id: 'content-releases.content-manager-edit-view.add-to-release.cancel-button', + defaultMessage: 'Cancel', + })} + + } + endActions={ + /** + * TODO: Ideally we would use isValid from Formik to disable the button, however currently it always returns true + * for yup.string().required(), even when the value is falsy (including empty string) + */ + + } + /> + + ); + }} +
+
+ ); +}; + +export const CMReleasesContainer = () => { + const [showModal, setShowModal] = React.useState(false); + const { formatMessage } = useIntl(); + const { + isCreatingEntry, + allLayoutData: { contentType }, + } = useCMEditViewDataManager(); + + const toggleAddActionToReleaseModal = () => setShowModal((prev) => !prev); + + /** + * - Impossible to add entry to release before it exists + * - Content types without draft and publish cannot add entries to release + * TODO v5: All contentTypes will have draft and publish enabled + */ + if (isCreatingEntry || !contentType?.options?.draftAndPublish) { + return null; + } + + return ( + + + + + {formatMessage({ + id: 'content-releases.plugin.name', + defaultMessage: 'RELEASES', + })} + + + + + + {showModal && } + + + ); +}; diff --git a/packages/core/content-releases/admin/src/components/ReleaseActionOptions.tsx b/packages/core/content-releases/admin/src/components/ReleaseActionOptions.tsx new file mode 100644 index 0000000000..f7f015d427 --- /dev/null +++ b/packages/core/content-releases/admin/src/components/ReleaseActionOptions.tsx @@ -0,0 +1,94 @@ +import * as React from 'react'; + +import { + FieldInput, + FieldLabel, + VisuallyHidden, + Field, + type FieldProps, +} from '@strapi/design-system'; +import styled from 'styled-components'; + +interface FieldWrapperProps extends FieldProps { + actionType: 'publish' | 'unpublish'; +} + +const getBorderLeftRadiusValue = (actionType: FieldWrapperProps['actionType']) => { + return actionType === 'publish' ? 1 : 0; +}; + +const getBorderRightRadiusValue = (actionType: FieldWrapperProps['actionType']) => { + return actionType === 'publish' ? 0 : 1; +}; + +const FieldWrapper = styled(Field)` + border-top-left-radius: ${({ actionType, theme }) => + theme.spaces[getBorderLeftRadiusValue(actionType)]}; + border-bottom-left-radius: ${({ actionType, theme }) => + theme.spaces[getBorderLeftRadiusValue(actionType)]}; + border-top-right-radius: ${({ actionType, theme }) => + theme.spaces[getBorderRightRadiusValue(actionType)]}; + border-bottom-right-radius: ${({ actionType, theme }) => + theme.spaces[getBorderRightRadiusValue(actionType)]}; + + > label { + color: inherit; + padding: ${({ theme }) => `${theme.spaces[2]} ${theme.spaces[3]}`}; + text-align: center; + vertical-align: middle; + text-transform: capitalize; + } + + &:active, + &[data-checked='true'] { + color: ${({ theme }) => theme.colors.primary700}; + background-color: ${({ theme }) => theme.colors.primary100}; + border-color: ${({ theme }) => theme.colors.primary700}; + } +`; + +interface ActionOptionProps { + selected: 'publish' | 'unpublish'; + handleChange: (e: React.ChangeEvent) => void; +} + +interface OptionProps extends ActionOptionProps { + actionType: 'publish' | 'unpublish'; +} + +const ActionOption = ({ selected, actionType, handleChange }: OptionProps) => { + return ( + + + + + + {actionType} + + + ); +}; + +export const ReleaseActionOptions = ({ selected, handleChange }: ActionOptionProps) => { + return ( + <> + + + + ); +}; diff --git a/packages/core/content-releases/admin/src/components/tests/CMReleasesContainer.test.tsx b/packages/core/content-releases/admin/src/components/tests/CMReleasesContainer.test.tsx new file mode 100644 index 0000000000..da5b850df8 --- /dev/null +++ b/packages/core/content-releases/admin/src/components/tests/CMReleasesContainer.test.tsx @@ -0,0 +1,111 @@ +import { useCMEditViewDataManager } from '@strapi/helper-plugin'; +import { screen } from '@testing-library/react'; +import { render, server } from '@tests/utils'; +import { rest } from 'msw'; + +import { CMReleasesContainer } from '../CMReleasesContainer'; + +jest.mock('@strapi/helper-plugin', () => ({ + ...jest.requireActual('@strapi/helper-plugin'), + // eslint-disable-next-line + CheckPermissions: ({ children }: { children: JSX.Element }) =>
{children}
, + useCMEditViewDataManager: jest.fn().mockReturnValue({ + isCreatingEntry: false, + allLayoutData: { + contentType: { + uid: 'api::article.article', + options: { + draftAndPublish: true, + }, + }, + }, + }), +})); + +describe('CMReleasesContainer', () => { + it('should not render the injection zone when creating an entry', () => { + // @ts-expect-error - Ignore error + useCMEditViewDataManager.mockReturnValueOnce({ + isCreatingEntry: true, + allLayoutData: { + contentType: { + options: { + draftAndPublish: true, + }, + }, + }, + }); + + render(); + + const informationBox = screen.queryByRole('complementary', { name: 'Releases' }); + expect(informationBox).not.toBeInTheDocument(); + }); + + it('should not render the injection zone without draft and publish enabled', () => { + // @ts-expect-error - Ignore error + useCMEditViewDataManager.mockReturnValueOnce({ + isCreatingEntry: false, + allLayoutData: { + contentType: { + options: { + draftAndPublish: false, + }, + }, + }, + }); + + render(); + + const informationBox = screen.queryByRole('complementary', { name: 'Releases' }); + expect(informationBox).not.toBeInTheDocument(); + }); + + it('should render the injection zone', () => { + render(); + + const addToReleaseButton = screen.getByRole('button', { name: 'Add to release' }); + const informationBox = screen.getByRole('complementary', { name: 'Releases' }); + expect(informationBox).toBeInTheDocument(); + expect(addToReleaseButton).toBeInTheDocument(); + }); + + it('should open and close the add to release modal', async () => { + const { user } = render(); + + const addToReleaseButton = screen.getByRole('button', { name: 'Add to release' }); + await user.click(addToReleaseButton); + const modalDialog = screen.getByRole('dialog', { name: 'Add to release' }); + expect(modalDialog).toBeVisible(); + + const closeButton = screen.getByRole('button', { name: 'Close the modal' }); + await user.click(closeButton); + expect(modalDialog).not.toBeVisible(); + }); + + it("should enable the modal's submit button", async () => { + // Mock the response from the server + server.use( + rest.get('/content-releases', (req, res, ctx) => { + return res( + ctx.json({ + data: [{ name: 'release1', id: '1' }], + }) + ); + }) + ); + + const { user } = render(); + + const addToReleaseButton = screen.getByRole('button', { name: 'Add to release' }); + await user.click(addToReleaseButton); + + // Select a value received from the server + const select = screen.getByRole('combobox', { name: 'Select a release' }); + await user.click(select); + await user.click(screen.getByRole('option', { name: 'release1' })); + + const submitButtom = screen.getByRole('button', { name: 'Continue' }); + expect(submitButtom).toBeEnabled(); + }); +}); diff --git a/packages/core/content-releases/admin/src/components/tests/ReleasActionOptions.test.tsx b/packages/core/content-releases/admin/src/components/tests/ReleasActionOptions.test.tsx new file mode 100644 index 0000000000..f903cbf162 --- /dev/null +++ b/packages/core/content-releases/admin/src/components/tests/ReleasActionOptions.test.tsx @@ -0,0 +1,21 @@ +import { fireEvent, screen } from '@testing-library/react'; +import { render } from '@tests/utils'; + +import { ReleaseActionOptions } from '../ReleaseActionOptions'; + +describe('ReleaseActionOptions', () => { + it('should render the component', () => { + const handleChange = jest.fn(); + render(); + + const publishOption = screen.getByRole('radio', { name: 'publish' }); + const unpublishOption = screen.getByRole('radio', { name: 'unpublish' }); + + expect(publishOption).toBeInTheDocument(); + expect(publishOption).toBeChecked(); + expect(unpublishOption).toBeInTheDocument(); + + fireEvent.click(unpublishOption); + expect(handleChange).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/core/content-releases/admin/src/constants.ts b/packages/core/content-releases/admin/src/constants.ts index 9454065561..455d334cf7 100644 --- a/packages/core/content-releases/admin/src/constants.ts +++ b/packages/core/content-releases/admin/src/constants.ts @@ -11,4 +11,10 @@ export const PERMISSIONS = { subject: null, }, ], + createAction: [ + { + action: 'plugin::content-releases.create-action', + subject: null, + }, + ], }; diff --git a/packages/core/content-releases/admin/src/index.ts b/packages/core/content-releases/admin/src/index.ts index 3f60051159..a1a889ff07 100644 --- a/packages/core/content-releases/admin/src/index.ts +++ b/packages/core/content-releases/admin/src/index.ts @@ -1,6 +1,7 @@ import { prefixPluginTranslations } from '@strapi/helper-plugin'; import { PaperPlane } from '@strapi/icons'; +import { CMReleasesContainer } from './components/CMReleasesContainer'; import { PERMISSIONS } from './constants'; import { pluginId } from './pluginId'; import { releaseApi } from './services/release'; @@ -35,6 +36,12 @@ const admin: Plugin.Config.AdminInput = { app.addReducers({ [releaseApi.reducerPath]: releaseApi.reducer, }); + + // Insert the Releases container in the 'right-links' zone of the Content Manager's edit view + app.injectContentManagerComponent('editView', 'right-links', { + name: `${pluginId}-link`, + Component: CMReleasesContainer, + }); } }, async registerTrads({ locales }: { locales: string[] }) { diff --git a/packages/core/content-releases/admin/src/pages/ReleasesPage.tsx b/packages/core/content-releases/admin/src/pages/ReleasesPage.tsx index 9ad332d3d8..025028c576 100644 --- a/packages/core/content-releases/admin/src/pages/ReleasesPage.tsx +++ b/packages/core/content-releases/admin/src/pages/ReleasesPage.tsx @@ -33,7 +33,7 @@ import styled from 'styled-components'; import { GetReleases } from '../../../shared/contracts/releases'; import { AddReleaseDialog } from '../components/AddReleaseDialog'; import { PERMISSIONS } from '../constants'; -import { useGetReleasesQuery, GetAllReleasesQueryParams } from '../services/release'; +import { useGetReleasesQuery, GetReleasesQueryParams } from '../services/release'; /* ------------------------------------------------------------------------------------------------- * ReleasesLayout @@ -167,7 +167,7 @@ const ReleasesGrid = ({ sectionTitle, releases = [], isError = false }: Releases const ReleasesPage = () => { const [addReleaseDialogIsShown, setAddReleaseDialogIsShown] = React.useState(false); const { formatMessage } = useIntl(); - const [{ query }, setQuery] = useQueryParams(); + const [{ query }, setQuery] = useQueryParams(); const response = useGetReleasesQuery(query); const { isLoading, isSuccess, isError } = response; diff --git a/packages/core/content-releases/admin/src/services/release.ts b/packages/core/content-releases/admin/src/services/release.ts index 0ca891864f..6fd719d01f 100644 --- a/packages/core/content-releases/admin/src/services/release.ts +++ b/packages/core/content-releases/admin/src/services/release.ts @@ -1,12 +1,13 @@ import { createApi } from '@reduxjs/toolkit/query/react'; +import { CreateReleaseAction } from '../../../shared/contracts/release-actions'; import { pluginId } from '../pluginId'; import { axiosBaseQuery } from './axios'; import type { CreateRelease, GetReleases } from '../../../shared/contracts/releases'; -export interface GetAllReleasesQueryParams { +interface GetReleasesQueryParams { page?: number; pageSize?: number; filters?: { @@ -17,13 +18,44 @@ export interface GetAllReleasesQueryParams { }; } +type GetReleasesTabResponse = GetReleases.Response & { + meta: { + activeTab: 'pending' | 'done'; + }; +}; + const releaseApi = createApi({ reducerPath: pluginId, baseQuery: axiosBaseQuery, tagTypes: ['Releases'], endpoints: (build) => { return { - getReleases: build.query({ + /** + * TODO: This will need to evolve to handle queries for: + * - Get all releases where the entry is attached + * - Get all releases where the entry is not attached + * + * We need to explore the best way to filter on polymorphic relations in another PR + */ + getReleasesForEntry: build.query({ + query() { + return { + url: '/content-releases', + method: 'GET', + config: { + params: { + filters: { + releasedAt: { + $notNull: false, + }, + }, + }, + }, + }; + }, + providesTags: ['Releases'], + }), + getReleases: build.query({ query( { page, pageSize, filters } = { page: 1, @@ -47,10 +79,10 @@ const releaseApi = createApi({ }, }; }, - transformResponse(response: GetReleases.Response, meta, arg) { + transformResponse(response: GetReleasesTabResponse, meta, arg) { const releasedAtValue = arg?.filters?.releasedAt?.$notNull; const isActiveDoneTab = releasedAtValue === 'true'; - const newResponse = { + const newResponse: GetReleasesTabResponse = { ...response, meta: { ...response.meta, @@ -72,10 +104,36 @@ const releaseApi = createApi({ }, invalidatesTags: ['Releases'], }), + createReleaseAction: build.mutation< + CreateReleaseAction.Response, + CreateReleaseAction.Request + >({ + query({ body, params }) { + return { + url: `/content-releases/${params.releaseId}/actions`, + method: 'POST', + data: body, + }; + }, + invalidatesTags: ['Releases'], + }), }; }, }); -const { useGetReleasesQuery, useCreateReleaseMutation } = releaseApi; +const { + useGetReleasesQuery, + useGetReleasesForEntryQuery, + useCreateReleaseMutation, + useCreateReleaseActionMutation, +} = releaseApi; -export { useGetReleasesQuery, useCreateReleaseMutation, releaseApi }; +export { + useGetReleasesQuery, + useGetReleasesForEntryQuery, + useCreateReleaseMutation, + useCreateReleaseActionMutation, + releaseApi, +}; + +export type { GetReleasesQueryParams }; diff --git a/packages/core/content-releases/admin/src/translations/en.json b/packages/core/content-releases/admin/src/translations/en.json index 651acd735c..5f5c989c96 100644 --- a/packages/core/content-releases/admin/src/translations/en.json +++ b/packages/core/content-releases/admin/src/translations/en.json @@ -1,4 +1,12 @@ { + "content-manager-edit-view.add-to-release.select-label": "Select a release", + "content-manager-edit-view.add-to-release.select-placeholder": "Select", + "content-manager-edit-view.add-to-release.action-type-label": "What do you want to do with this entry?", + "content-manager-edit-view.add-to-release.cancel-button": "Cancel", + "content-manager-edit-view.add-to-release.continue-button": "Continue", + "content-manager-edit-view.add-to-release": "Add to release", + "content-manager-edit-view.add-to-release.notification.success": "Entry added to release", + "content-manager.notification.entry-error": "Failed to get entry data", "plugin.name": "Releases", "pages.Releases.title": "Releases", "pages.Releases.header-subtitle": "{number, plural, =0 {No releases} one {# release} other {# releases}}", diff --git a/packages/core/content-releases/server/src/controllers/__tests__/release.test.ts b/packages/core/content-releases/server/src/controllers/__tests__/release.test.ts index d9efe5036d..ba045f3d22 100644 --- a/packages/core/content-releases/server/src/controllers/__tests__/release.test.ts +++ b/packages/core/content-releases/server/src/controllers/__tests__/release.test.ts @@ -1,6 +1,98 @@ import releaseController from '../release'; describe('Release controller', () => { + describe('findMany', () => { + it('should call findPage', async () => { + const findPage = jest.fn().mockResolvedValue({ results: [], pagination: {} }); + const findMany = jest.fn().mockResolvedValue([]); + const userAbility = { + can: jest.fn(), + }; + const ctx = { + state: { + userAbility: {}, + }, + query: { + page: 1, + pageSize: 10, + }, + }; + global.strapi = { + // @ts-expect-error Ignore missing properties + admin: { + services: { + permission: { + createPermissionsManager: jest.fn(() => ({ + ability: userAbility, + validateQuery: jest.fn(), + sanitizeQuery: jest.fn(() => ctx.query), + })), + }, + }, + }, + plugins: { + // @ts-expect-error Ignore missing properties + 'content-releases': { + services: { + release: { + findPage, + findMany, + }, + }, + }, + }, + }; + + // @ts-expect-error partial context + await releaseController.findMany(ctx); + + expect(findPage).toHaveBeenCalled(); + }); + + it('should call findMany', async () => { + const findPage = jest.fn().mockResolvedValue({ results: [], pagination: {} }); + const findMany = jest.fn().mockResolvedValue([]); + const userAbility = { + can: jest.fn(), + }; + const ctx = { + state: { + userAbility: {}, + }, + query: {}, + }; + global.strapi = { + // @ts-expect-error Ignore missing properties + admin: { + services: { + permission: { + createPermissionsManager: jest.fn(() => ({ + ability: userAbility, + validateQuery: jest.fn(), + sanitizeQuery: jest.fn(() => ctx.query), + })), + }, + }, + }, + plugins: { + // @ts-expect-error Ignore missing properties + 'content-releases': { + services: { + release: { + findPage, + findMany, + }, + }, + }, + }, + }; + + // @ts-expect-error partial context + await releaseController.findMany(ctx); + + expect(findMany).toHaveBeenCalled(); + }); + }); describe('create', () => { it('throws an error given bad request arguments', () => { const ctx = { @@ -17,7 +109,7 @@ describe('Release controller', () => { expect(() => releaseController.create(ctx)).rejects.toThrow('name is a required field'); }); }); - + describe('update', () => { it('throws an error given bad request arguments', () => { const ctx = { @@ -27,18 +119,18 @@ describe('Release controller', () => { // Mock missing name on request request: { body: { - name: '' + name: '', }, }, params: { - id: 1 + id: 1, }, }; - + // @ts-expect-error partial context expect(() => releaseController.update(ctx)).rejects.toThrow('name is a required field'); }); - + it('throws an error given unknown request arguments', () => { const ctx = { state: { @@ -48,16 +140,18 @@ describe('Release controller', () => { request: { body: { name: 'Test', - unknown: '' + unknown: '', }, }, params: { - id: 1 + id: 1, }, }; - + // @ts-expect-error partial context - expect(() => releaseController.update(ctx)).rejects.toThrow('this field has unspecified keys: unknown'); + expect(() => releaseController.update(ctx)).rejects.toThrow( + 'this field has unspecified keys: unknown' + ); }); }); }); diff --git a/packages/core/content-releases/server/src/controllers/release-action.ts b/packages/core/content-releases/server/src/controllers/release-action.ts index 2f3d18122e..ae2ca3a478 100644 --- a/packages/core/content-releases/server/src/controllers/release-action.ts +++ b/packages/core/content-releases/server/src/controllers/release-action.ts @@ -1,5 +1,5 @@ import type Koa from 'koa'; -import { validateReleaseActionCreateSchema } from './validation/release-action'; +import { validateReleaseAction } from './validation/release-action'; import type { CreateReleaseAction } from '../../../shared/contracts/release-actions'; import { getService } from '../utils'; @@ -8,7 +8,7 @@ const releaseActionController = { const releaseId: CreateReleaseAction.Request['params']['releaseId'] = ctx.params.releaseId; const releaseActionArgs: CreateReleaseAction.Request['body'] = ctx.request.body; - await validateReleaseActionCreateSchema(releaseActionArgs); + await validateReleaseAction(releaseActionArgs); const releaseService = getService('release', { strapi }); const releaseAction = await releaseService.createAction(releaseId, releaseActionArgs); diff --git a/packages/core/content-releases/server/src/controllers/release.ts b/packages/core/content-releases/server/src/controllers/release.ts index 753c6f9546..518f3968e1 100644 --- a/packages/core/content-releases/server/src/controllers/release.ts +++ b/packages/core/content-releases/server/src/controllers/release.ts @@ -13,6 +13,21 @@ import { getService } from '../utils'; type ReleaseWithPopulatedActions = Release & { actions: { count: number } }; +const formatDataObject = (releases: ReleaseWithPopulatedActions[]) => { + return releases.map((release) => { + const { actions, ...releaseData } = release; + + return { + ...releaseData, + actions: { + meta: { + count: actions.count, + }, + }, + }; + }); +}; + const releaseController = { async findMany(ctx: Koa.Context) { const permissionsManager = strapi.admin.services.permission.createPermissionsManager({ @@ -23,23 +38,19 @@ const releaseController = { await permissionsManager.validateQuery(ctx.query); const query = await permissionsManager.sanitizeQuery(ctx.query); - const { results, pagination } = await getService('release', { strapi }).findMany(query); + const isPaginatedRequest = + query && Object.keys(query).some((key) => ['page', 'pageSize'].includes(key)); - // Format the data object - const data = results.map((release: ReleaseWithPopulatedActions) => { - const { actions, ...releaseData } = release; + if (isPaginatedRequest) { + const { results, pagination } = await getService('release', { strapi }).findPage(query); + // Format the data object + const data = formatDataObject(results); - return { - ...releaseData, - actions: { - meta: { - count: actions.count, - }, - }, - }; - }); - - ctx.body = { data, meta: { pagination } }; + ctx.body = { data, meta: { pagination } }; + } else { + const results = await getService('release', { strapi }).findMany(query); + ctx.body = { data: formatDataObject(results) }; + } }, async findOne(ctx: Koa.Context) { diff --git a/packages/core/content-releases/server/src/controllers/validation/release-action.ts b/packages/core/content-releases/server/src/controllers/validation/release-action.ts index e85f8619b8..04020c8a83 100644 --- a/packages/core/content-releases/server/src/controllers/validation/release-action.ts +++ b/packages/core/content-releases/server/src/controllers/validation/release-action.ts @@ -1,14 +1,14 @@ import { yup, validateYupSchema } from '@strapi/utils'; -const releaseActionCreateSchema = yup.object().shape({ +const RELEASE_ACTION_SCHEMA = yup.object().shape({ entry: yup .object() .shape({ - id: yup.number().required(), + id: yup.strapiID().required(), contentType: yup.string().required(), }) .required(), type: yup.string().oneOf(['publish', 'unpublish']).required(), }); -export const validateReleaseActionCreateSchema = validateYupSchema(releaseActionCreateSchema); +export const validateReleaseAction = validateYupSchema(RELEASE_ACTION_SCHEMA); diff --git a/packages/core/content-releases/server/src/controllers/validation/release.ts b/packages/core/content-releases/server/src/controllers/validation/release.ts index a08e5fcfa3..90dc8928f3 100644 --- a/packages/core/content-releases/server/src/controllers/validation/release.ts +++ b/packages/core/content-releases/server/src/controllers/validation/release.ts @@ -1,11 +1,4 @@ -import { yup, validateYupSchema } from '@strapi/utils'; +import { validateYupSchema } from '@strapi/utils'; +import { RELEASE_SCHEMA } from '../../../../shared/validation-schemas'; -const validateReleaseSchema = yup - .object() - .shape({ - name: yup.string().trim().required(), - }) - .required() - .noUnknown(); - -export const validateRelease = validateYupSchema(validateReleaseSchema); +export const validateRelease = validateYupSchema(RELEASE_SCHEMA); diff --git a/packages/core/content-releases/server/src/services/release.ts b/packages/core/content-releases/server/src/services/release.ts index def1fd43c5..7a89a77f58 100644 --- a/packages/core/content-releases/server/src/services/release.ts +++ b/packages/core/content-releases/server/src/services/release.ts @@ -1,7 +1,12 @@ import { setCreatorFields, errors } from '@strapi/utils'; import type { LoadedStrapi } from '@strapi/types'; import { RELEASE_ACTION_MODEL_UID, RELEASE_MODEL_UID } from '../constants'; -import type { GetReleases, CreateRelease, UpdateRelease, GetRelease } from '../../../shared/contracts/releases'; +import type { + GetReleases, + CreateRelease, + UpdateRelease, + GetRelease, +} from '../../../shared/contracts/releases'; import type { CreateReleaseAction } from '../../../shared/contracts/release-actions'; import type { UserInfo } from '../../../shared/types'; import { getService } from '../utils'; @@ -14,7 +19,7 @@ const createReleaseService = ({ strapi }: { strapi: LoadedStrapi }) => ({ data: releaseWithCreatorFields, }); }, - findMany(query?: GetReleases.Request['query']) { + findPage(query?: GetReleases.Request['query']) { return strapi.entityService.findPage(RELEASE_MODEL_UID, { ...query, populate: { @@ -25,6 +30,17 @@ const createReleaseService = ({ strapi }: { strapi: LoadedStrapi }) => ({ }, }); }, + findMany(query?: GetReleases.Request['query']) { + return strapi.entityService.findMany(RELEASE_MODEL_UID, { + ...query, + populate: { + actions: { + // @ts-expect-error TS error on populate, is not considering count + count: true, + }, + }, + }); + }, findOne(id: GetRelease.Request['params']['id']) { return strapi.entityService.findOne(RELEASE_MODEL_UID, id, { populate: { @@ -35,11 +51,21 @@ const createReleaseService = ({ strapi }: { strapi: LoadedStrapi }) => ({ }, }); }, - async update(id: number, releaseData: UpdateRelease.Request['body'], { user }: { user: UserInfo }) { + async update( + id: number, + releaseData: UpdateRelease.Request['body'], + { user }: { user: UserInfo } + ) { const updatedRelease = await setCreatorFields({ user, isEdition: true })(releaseData); - // @ts-expect-error Type 'ReleaseUpdateArgs' has no properties in common with type 'Partial>' - const release = await strapi.entityService.update(RELEASE_MODEL_UID, id, { data: updatedRelease }); + const release = await strapi.entityService.update(RELEASE_MODEL_UID, id, { + /* + * The type returned from the entity service: Partial> + * is not compatible with the type we are passing here: UpdateRelease.Request['body'] + */ + // @ts-expect-error see above + data: updatedRelease, + }); if (!release) { throw new errors.NotFoundError(`No release found for id ${id}`); diff --git a/packages/core/content-releases/server/src/services/validation.ts b/packages/core/content-releases/server/src/services/validation.ts index 20babcdfe8..04615375fe 100644 --- a/packages/core/content-releases/server/src/services/validation.ts +++ b/packages/core/content-releases/server/src/services/validation.ts @@ -23,7 +23,7 @@ const createReleaseValidationService = ({ strapi }: { strapi: LoadedStrapi }) => const isEntryInRelease = release.actions.some( (action) => - action.entry.id === releaseActionArgs.entry.id && + Number(action.entry.id) === Number(releaseActionArgs.entry.id) && action.contentType === releaseActionArgs.entry.contentType ); diff --git a/packages/core/content-releases/shared/contracts/releases.ts b/packages/core/content-releases/shared/contracts/releases.ts index e217013c55..78c2b5bb37 100644 --- a/packages/core/content-releases/shared/contracts/releases.ts +++ b/packages/core/content-releases/shared/contracts/releases.ts @@ -34,8 +34,7 @@ export declare namespace GetReleases { export interface Response { data: ReleaseDataResponse[]; meta: { - pagination: Pagination; - activeTab?: string; + pagination?: Pagination; }; error?: errors.ApplicationError; } diff --git a/packages/core/content-releases/shared/validation-schemas.ts b/packages/core/content-releases/shared/validation-schemas.ts new file mode 100644 index 0000000000..da618aa361 --- /dev/null +++ b/packages/core/content-releases/shared/validation-schemas.ts @@ -0,0 +1,9 @@ +import * as yup from 'yup'; + +export const RELEASE_SCHEMA = yup + .object() + .shape({ + name: yup.string().trim().required(), + }) + .required() + .noUnknown(); diff --git a/packages/core/helper-plugin/src/components/CheckPermissions.tsx b/packages/core/helper-plugin/src/components/CheckPermissions.tsx index 8e48f26209..69e328595a 100644 --- a/packages/core/helper-plugin/src/components/CheckPermissions.tsx +++ b/packages/core/helper-plugin/src/components/CheckPermissions.tsx @@ -14,6 +14,7 @@ export interface CheckPermissionsProps { const CheckPermissions = ({ permissions = [], children }: CheckPermissionsProps) => { const { allPermissions } = useRBACProvider(); + const toggleNotification = useNotification(); const [state, setState] = React.useState({ isLoading: true, canAccess: false }); const isMounted = React.useRef(true);