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 (
+
+ );
+ }}
+
+
+ );
+};
+
+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',
+ })}
+
+
+ }
+ onClick={toggleAddActionToReleaseModal}
+ >
+ {formatMessage({
+ id: 'content-releases.content-manager-edit-view.add-to-release',
+ defaultMessage: 'Add to release',
+ })}
+
+
+
+ {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);