mirror of
https://github.com/strapi/strapi.git
synced 2025-11-02 10:55:37 +00:00
feat(content-releases): add create release action to cm edit view (#18905)
This commit is contained in:
parent
8145b7becc
commit
4f6722c6d4
@ -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();
|
||||
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
@ -11,4 +11,10 @@ export const PERMISSIONS = {
|
||||
subject: null,
|
||||
},
|
||||
],
|
||||
createAction: [
|
||||
{
|
||||
action: 'plugin::content-releases.create-action',
|
||||
subject: null,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@ -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[] }) {
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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 };
|
||||
|
||||
@ -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}}",
|
||||
|
||||
@ -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'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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}`);
|
||||
|
||||
@ -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
|
||||
);
|
||||
|
||||
|
||||
@ -34,8 +34,7 @@ export declare namespace GetReleases {
|
||||
export interface Response {
|
||||
data: ReleaseDataResponse[];
|
||||
meta: {
|
||||
pagination: Pagination;
|
||||
activeTab?: string;
|
||||
pagination?: Pagination;
|
||||
};
|
||||
error?: errors.ApplicationError;
|
||||
}
|
||||
|
||||
@ -0,0 +1,9 @@
|
||||
import * as yup from 'yup';
|
||||
|
||||
export const RELEASE_SCHEMA = yup
|
||||
.object()
|
||||
.shape({
|
||||
name: yup.string().trim().required(),
|
||||
})
|
||||
.required()
|
||||
.noUnknown();
|
||||
@ -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);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user