feat(content-releases): add create release action to cm edit view (#18905)

This commit is contained in:
markkaylor 2023-12-01 09:27:16 +01:00 committed by GitHub
parent 8145b7becc
commit 4f6722c6d4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 767 additions and 61 deletions

View File

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

View File

@ -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 (
<ModalLayout onClose={handleClose} labelledBy="title">
<ModalHeader>
<Typography id="title" fontWeight="bold" textColor="neutral800">
{formatMessage({
id: 'content-releases.content-manager-edit-view.add-to-release',
defaultMessage: 'Add to release',
})}
</Typography>
</ModalHeader>
<Formik
onSubmit={handleSubmit}
validationSchema={RELEASE_ACTION_FORM_SCHEMA}
initialValues={INITIAL_VALUES}
>
{({ values, setFieldValue }) => {
return (
<Form>
<ModalBody>
<Flex direction="column" alignItems="stretch" gap={2}>
<Box paddingBottom={6}>
<SingleSelect
required
label={formatMessage({
id: 'content-releases.content-manager-edit-view.add-to-release.select-label',
defaultMessage: 'Select a release',
})}
placeholder={formatMessage({
id: 'content-releases.content-manager-edit-view.add-to-release.select-placeholder',
defaultMessage: 'Select',
})}
onChange={(value) => setFieldValue('releaseId', value)}
value={values.releaseId}
>
{releases?.map((release) => (
<SingleSelectOption key={release.id} value={release.id}>
{release.name}
</SingleSelectOption>
))}
</SingleSelect>
</Box>
<FieldLabel>
{formatMessage({
id: 'content-releases.content-manager-edit-view.add-to-release.action-type-label',
defaultMessage: 'What do you want to do with this entry?',
})}
</FieldLabel>
<Flex>
<ReleaseActionOptions
selected={values.type}
handleChange={(e) => setFieldValue('type', e.target.value)}
/>
</Flex>
</Flex>
</ModalBody>
<ModalFooter
startActions={
<Button onClick={handleClose} variant="tertiary" name="cancel">
{formatMessage({
id: 'content-releases.content-manager-edit-view.add-to-release.cancel-button',
defaultMessage: 'Cancel',
})}
</Button>
}
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)
*/
<Button type="submit" disabled={!values.releaseId} loading={isLoading}>
{formatMessage({
id: 'content-releases.content-manager-edit-view.add-to-release.continue-button',
defaultMessage: 'Continue',
})}
</Button>
}
/>
</Form>
);
}}
</Formik>
</ModalLayout>
);
};
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 (
<CheckPermissions permissions={PERMISSIONS.main}>
<Box
as="aside"
aria-label={formatMessage({
id: 'content-releases.plugin.name',
defaultMessage: 'Releases',
})}
background="neutral0"
borderColor="neutral150"
hasRadius
padding={4}
shadow="tableShadow"
>
<Flex direction="column" alignItems="stretch" gap={4}>
<Typography variant="sigma" textColor="neutral600" textTransform="uppercase">
{formatMessage({
id: 'content-releases.plugin.name',
defaultMessage: 'RELEASES',
})}
</Typography>
<CheckPermissions permissions={PERMISSIONS.createAction}>
<Button
justifyContent="center"
paddingLeft={4}
paddingRight={4}
color="neutral700"
variant="tertiary"
startIcon={<Plus />}
onClick={toggleAddActionToReleaseModal}
>
{formatMessage({
id: 'content-releases.content-manager-edit-view.add-to-release',
defaultMessage: 'Add to release',
})}
</Button>
</CheckPermissions>
</Flex>
{showModal && <AddActionToReleaseModal handleClose={toggleAddActionToReleaseModal} />}
</Box>
</CheckPermissions>
);
};

View File

@ -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)<FieldWrapperProps>`
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<HTMLInputElement>) => void;
}
interface OptionProps extends ActionOptionProps {
actionType: 'publish' | 'unpublish';
}
const ActionOption = ({ selected, actionType, handleChange }: OptionProps) => {
return (
<FieldWrapper
actionType={actionType}
background="primary0"
borderColor="neutral200"
color={selected === actionType ? 'primary600' : 'neutral600'}
position="relative"
cursor="pointer"
data-checked={selected === actionType}
>
<FieldLabel htmlFor={`release-action-${actionType}`}>
<VisuallyHidden>
<FieldInput
type="radio"
id={`release-action-${actionType}`}
name="type"
checked={selected === actionType}
onChange={handleChange}
value={actionType}
/>
</VisuallyHidden>
{actionType}
</FieldLabel>
</FieldWrapper>
);
};
export const ReleaseActionOptions = ({ selected, handleChange }: ActionOptionProps) => {
return (
<>
<ActionOption actionType="publish" selected={selected} handleChange={handleChange} />
<ActionOption actionType="unpublish" selected={selected} handleChange={handleChange} />
</>
);
};

View File

@ -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 }) => <div>{children}</div>,
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(<CMReleasesContainer />);
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(<CMReleasesContainer />);
const informationBox = screen.queryByRole('complementary', { name: 'Releases' });
expect(informationBox).not.toBeInTheDocument();
});
it('should render the injection zone', () => {
render(<CMReleasesContainer />);
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(<CMReleasesContainer />);
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(<CMReleasesContainer />);
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();
});
});

View File

@ -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(<ReleaseActionOptions selected="publish" handleChange={handleChange} />);
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);
});
});

View File

@ -11,4 +11,10 @@ export const PERMISSIONS = {
subject: null,
},
],
createAction: [
{
action: 'plugin::content-releases.create-action',
subject: null,
},
],
};

View File

@ -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[] }) {

View File

@ -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<GetAllReleasesQueryParams>();
const [{ query }, setQuery] = useQueryParams<GetReleasesQueryParams>();
const response = useGetReleasesQuery(query);
const { isLoading, isSuccess, isError } = response;

View File

@ -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<GetReleases.Response, GetAllReleasesQueryParams | void>({
/**
* 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<GetReleases.Response, GetReleasesQueryParams | void>({
query() {
return {
url: '/content-releases',
method: 'GET',
config: {
params: {
filters: {
releasedAt: {
$notNull: false,
},
},
},
},
};
},
providesTags: ['Releases'],
}),
getReleases: build.query<GetReleasesTabResponse, GetReleasesQueryParams | void>({
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 };

View File

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

View File

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

View File

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

View File

@ -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) {

View File

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

View File

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

View File

@ -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<Input<"plugin::content-releases.release">>'
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<Input<"plugin::content-releases.release">>
* 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}`);

View File

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

View File

@ -34,8 +34,7 @@ export declare namespace GetReleases {
export interface Response {
data: ReleaseDataResponse[];
meta: {
pagination: Pagination;
activeTab?: string;
pagination?: Pagination;
};
error?: errors.ApplicationError;
}

View File

@ -0,0 +1,9 @@
import * as yup from 'yup';
export const RELEASE_SCHEMA = yup
.object()
.shape({
name: yup.string().trim().required(),
})
.required()
.noUnknown();

View File

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