feat(content-releases): Edit Release (#18956)

* first draft implementation edit release

* add dialog unit test

* add permission to the edit button

* add permissions type and remove old unit test

* add createAction to the PermissionMap

* fix type errors

* fix unit test

* fix lint error

* fix review comments

* change state naming

* change dialog to modal
This commit is contained in:
Simone 2023-12-04 13:58:29 +01:00 committed by GitHub
parent 9b4c03b10b
commit b4936e04a9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 303 additions and 110 deletions

View File

@ -7,65 +7,29 @@ import {
TextInput,
Typography,
} from '@strapi/design-system';
import { useAPIErrorHandler, useNotification } from '@strapi/helper-plugin';
import { Formik, Form } from 'formik';
import { useIntl } from 'react-intl';
import { useHistory } from 'react-router-dom';
import { RELEASE_SCHEMA } from '../../../shared/validation-schemas';
import { isAxiosError } from '../services/axios';
import { useCreateReleaseMutation } from '../services/release';
interface FormValues {
export interface FormValues {
name: string;
}
const INITIAL_VALUES = {
name: '',
} satisfies FormValues;
interface AddReleaseDialogProps {
interface ReleaseModalProps {
handleClose: () => void;
handleSubmit: (values: FormValues) => void;
isLoading?: boolean;
initialValues: FormValues;
}
export const AddReleaseDialog = ({ handleClose }: AddReleaseDialogProps) => {
export const ReleaseModal = ({
handleClose,
handleSubmit,
initialValues,
isLoading = false,
}: ReleaseModalProps) => {
const { formatMessage } = useIntl();
const toggleNotification = useNotification();
const { formatAPIError } = useAPIErrorHandler();
const { push } = useHistory();
const [createRelease, { isLoading }] = useCreateReleaseMutation();
const handleSubmit = async (values: FormValues) => {
const response = await createRelease({
name: values.name,
});
if ('data' in response) {
// When the response returns an object with 'data', handle success
toggleNotification({
type: 'success',
message: formatMessage({
id: 'content-releases.modal.release-created-notification-success',
defaultMessage: 'Release created.',
}),
});
push(`/plugins/content-releases/${response.data.data.id}`);
} else if (isAxiosError(response.error)) {
// When the response returns an object with 'error', handle axios error
toggleNotification({
type: 'warning',
message: formatAPIError(response.error),
});
} else {
// Otherwise, the response returns an object with 'error', handle a generic error
toggleNotification({
type: 'warning',
message: formatMessage({ id: 'notification.error', defaultMessage: 'An error occurred' }),
});
}
};
return (
<ModalLayout onClose={handleClose} labelledBy="title">
@ -80,7 +44,7 @@ export const AddReleaseDialog = ({ handleClose }: AddReleaseDialogProps) => {
<Formik
validateOnChange={false}
onSubmit={handleSubmit}
initialValues={INITIAL_VALUES}
initialValues={initialValues}
validationSchema={RELEASE_SCHEMA}
>
{({ values, errors, handleChange }) => (

View File

@ -1,26 +0,0 @@
import { within } from '@testing-library/react';
import { render, screen } from '@tests/utils';
import { AddReleaseDialog } from '../AddReleaseDialog';
describe('AddReleaseDialog', () => {
it('renders correctly the dialog content', async () => {
const handleCloseMocked = jest.fn();
const { user } = render(<AddReleaseDialog handleClose={handleCloseMocked} />);
const dialogContainer = screen.getByRole('dialog');
const dialogCancelButton = within(dialogContainer).getByRole('button', {
name: /cancel/i,
});
expect(dialogCancelButton).toBeInTheDocument();
await user.click(dialogCancelButton);
expect(handleCloseMocked).toHaveBeenCalledTimes(1);
// enable the submit button when there is content inside the input
const dialogContinueButton = within(dialogContainer).getByRole('button', {
name: /continue/i,
});
const inputElement = within(dialogContainer).getByRole('textbox', { name: /name/i });
await user.type(inputElement, 'new release');
expect(dialogContinueButton).toBeEnabled();
});
});

View File

@ -0,0 +1,58 @@
import { within } from '@testing-library/react';
import { render, screen } from '@tests/utils';
import { ReleaseModal } from '../ReleaseModal';
describe('ReleaseModal', () => {
it('renders correctly the dialog content on create', async () => {
const handleCloseMocked = jest.fn();
const { user } = render(
<ReleaseModal
handleClose={handleCloseMocked}
handleSubmit={jest.fn()}
initialValues={{ name: '' }}
isLoading={false}
/>
);
const dialogContainer = screen.getByRole('dialog');
const dialogCancelButton = within(dialogContainer).getByRole('button', {
name: /cancel/i,
});
expect(dialogCancelButton).toBeInTheDocument();
await user.click(dialogCancelButton);
expect(handleCloseMocked).toHaveBeenCalledTimes(1);
// the initial field value is empty
const inputElement = within(dialogContainer).getByRole('textbox', { name: /name/i });
expect(inputElement).toHaveValue('');
// enable the submit button when there is content inside the input
const dialogContinueButton = within(dialogContainer).getByRole('button', {
name: /continue/i,
});
await user.type(inputElement, 'new release');
expect(dialogContinueButton).toBeEnabled();
});
it('renders correctly the dialog content on update', async () => {
const handleCloseMocked = jest.fn();
render(
<ReleaseModal
handleClose={handleCloseMocked}
handleSubmit={jest.fn()}
initialValues={{ name: 'title' }}
isLoading={false}
/>
);
const dialogContainer = screen.getByRole('dialog');
// the initial field value is the title
const inputElement = within(dialogContainer).getByRole('textbox', { name: /name/i });
expect(inputElement).toHaveValue('title');
// enable the submit button when there is content inside the input
const dialogContinueButton = within(dialogContainer).getByRole('button', {
name: /continue/i,
});
expect(dialogContinueButton).toBeEnabled();
});
});

View File

@ -1,20 +1,62 @@
export const PERMISSIONS = {
import { Permission } from '@strapi/helper-plugin';
interface PermissionMap {
main: Permission[];
create: Permission[];
update: Permission[];
delete: Permission[];
createAction: Permission[];
}
export const PERMISSIONS: PermissionMap = {
main: [
{
id: 293,
action: 'plugin::content-releases.read',
subject: null,
conditions: [],
actionParameters: [],
properties: {},
},
],
create: [
{
id: 294,
action: 'plugin::content-releases.create',
subject: null,
conditions: [],
actionParameters: [],
properties: {},
},
],
update: [
{
id: 295,
action: 'plugin::content-releases.update',
subject: null,
conditions: [],
actionParameters: [],
properties: {},
},
],
delete: [
{
id: 296,
action: 'plugin::content-releases.delete',
subject: null,
conditions: [],
actionParameters: [],
properties: {},
},
],
createAction: [
{
id: 297,
action: 'plugin::content-releases.create-action',
subject: null,
conditions: [],
actionParameters: [],
properties: {},
},
],
};

View File

@ -12,12 +12,16 @@ import {
Popover,
Typography,
} from '@strapi/design-system';
import { CheckPermissions } from '@strapi/helper-plugin';
import { CheckPermissions, useAPIErrorHandler, useNotification } from '@strapi/helper-plugin';
import { ArrowLeft, EmptyDocuments, More, Pencil, Trash } from '@strapi/icons';
import { useIntl } from 'react-intl';
import { useParams } from 'react-router-dom';
import styled from 'styled-components';
import { ReleaseModal, FormValues } from '../components/ReleaseModal';
import { PERMISSIONS } from '../constants';
import { isAxiosError } from '../services/axios';
import { useUpdateReleaseMutation } from '../services/release';
const PopoverButton = styled(Flex)`
align-self: stretch;
@ -46,10 +50,14 @@ const ReleaseInfoWrapper = styled(Flex)`
`;
const ReleaseDetailsPage = () => {
const { releaseId } = useParams<{ releaseId: string }>();
const [releaseModalShown, setReleaseModalShown] = React.useState(false);
const [isPopoverVisible, setIsPopoverVisible] = React.useState(false);
const moreButtonRef = React.useRef<HTMLButtonElement>(null!);
const { formatMessage } = useIntl();
// TODO: get the title from the API
const toggleNotification = useNotification();
const { formatAPIError } = useAPIErrorHandler();
// TODO: get title from the API
const title = 'Release title';
const totalEntries = 0; // TODO: replace it with the total number of entries
@ -60,6 +68,47 @@ const ReleaseDetailsPage = () => {
setIsPopoverVisible((prev) => !prev);
};
const toggleEditReleaseModal = () => {
setReleaseModalShown((prev) => !prev);
};
const openReleaseModal = () => {
toggleEditReleaseModal();
handleTogglePopover();
};
const [updateRelease, { isLoading }] = useUpdateReleaseMutation();
const handleEditRelease = async (values: FormValues) => {
const response = await updateRelease({
id: releaseId,
name: values.name,
});
if ('data' in response) {
// When the response returns an object with 'data', handle success
toggleNotification({
type: 'success',
message: formatMessage({
id: 'content-releases.modal.release-updated-notification-success',
defaultMessage: 'Release updated.',
}),
});
} else if (isAxiosError(response.error)) {
// When the response returns an object with 'error', handle axios error
toggleNotification({
type: 'warning',
message: formatAPIError(response.error),
});
} else {
// Otherwise, the response returns an object with 'error', handle a generic error
toggleNotification({
type: 'warning',
message: formatMessage({ id: 'notification.error', defaultMessage: 'An error occurred' }),
});
}
toggleEditReleaseModal();
};
return (
<Main>
<HeaderLayout
@ -100,6 +149,28 @@ const ReleaseDetailsPage = () => {
minWidth="242px"
>
<Flex alignItems="center" justifyContent="center" direction="column" padding={1}>
<CheckPermissions permissions={PERMISSIONS.update}>
<PopoverButton
paddingTop={2}
paddingBottom={2}
paddingLeft={4}
paddingRight={4}
alignItems="center"
gap={2}
as="button"
hasRadius
onClick={openReleaseModal}
>
<PencilIcon />
<Typography ellipsis>
{formatMessage({
id: 'content-releases.header.actions.edit',
defaultMessage: 'Edit',
})}
</Typography>
</PopoverButton>
</CheckPermissions>
<PopoverButton
paddingTop={2}
paddingBottom={2}
@ -108,25 +179,7 @@ const ReleaseDetailsPage = () => {
alignItems="center"
gap={2}
as="button"
borderRadius="4px"
>
<PencilIcon />
<Typography ellipsis>
{formatMessage({
id: 'content-releases.header.actions.edit',
defaultMessage: 'Edit',
})}
</Typography>
</PopoverButton>
<PopoverButton
paddingTop={2}
paddingBottom={2}
paddingLeft={4}
paddingRight={4}
alignItems="center"
gap={2}
as="button"
borderRadius="4px"
hasRadius
>
<TrashIcon />
<Typography ellipsis textColor="danger600">
@ -187,6 +240,14 @@ const ReleaseDetailsPage = () => {
icon={<EmptyDocuments width="10rem" />}
/>
</ContentLayout>
{releaseModalShown && (
<ReleaseModal
handleClose={toggleEditReleaseModal}
handleSubmit={handleEditRelease}
isLoading={isLoading}
initialValues={{ name: title }}
/>
)}
</Main>
);
};

View File

@ -25,15 +25,23 @@ import {
PageSizeURLQuery,
PaginationURLQuery,
useQueryParams,
useAPIErrorHandler,
useNotification,
} from '@strapi/helper-plugin';
import { EmptyDocuments, Plus } from '@strapi/icons';
import { useIntl } from 'react-intl';
import { useHistory } from 'react-router-dom';
import styled from 'styled-components';
import { GetReleases } from '../../../shared/contracts/releases';
import { AddReleaseDialog } from '../components/AddReleaseDialog';
import { ReleaseModal, FormValues } from '../components/ReleaseModal';
import { PERMISSIONS } from '../constants';
import { useGetReleasesQuery, GetReleasesQueryParams } from '../services/release';
import { isAxiosError } from '../services/axios';
import {
useGetReleasesQuery,
GetReleasesQueryParams,
useCreateReleaseMutation,
} from '../services/release';
/* -------------------------------------------------------------------------------------------------
* ReleasesLayout
@ -164,21 +172,29 @@ const ReleasesGrid = ({ sectionTitle, releases = [], isError = false }: Releases
/* -------------------------------------------------------------------------------------------------
* ReleasesPage
* -----------------------------------------------------------------------------------------------*/
const INITIAL_FORM_VALUES = {
name: '',
} satisfies FormValues;
const ReleasesPage = () => {
const [addReleaseDialogIsShown, setAddReleaseDialogIsShown] = React.useState(false);
const [releaseModalShown, setReleaseModalShown] = React.useState(false);
const toggleNotification = useNotification();
const { formatMessage } = useIntl();
const { push } = useHistory();
const { formatAPIError } = useAPIErrorHandler();
const [{ query }, setQuery] = useQueryParams<GetReleasesQueryParams>();
const response = useGetReleasesQuery(query);
const [createRelease, { isLoading: isSubmittingForm }] = useCreateReleaseMutation();
const { isLoading, isSuccess, isError } = response;
const toggleAddReleaseDialog = () => {
setAddReleaseDialogIsShown((prev) => !prev);
const toggleAddReleaseModal = () => {
setReleaseModalShown((prev) => !prev);
};
if (isLoading) {
return (
<ReleasesLayout onClickAddRelease={toggleAddReleaseDialog} isLoading>
<ReleasesLayout onClickAddRelease={toggleAddReleaseModal} isLoading>
<ContentLayout>
<LoadingIndicatorPage />
</ContentLayout>
@ -203,8 +219,38 @@ const ReleasesPage = () => {
const activeTab = response?.currentData?.meta?.activeTab || 'pending';
const handleAddRelease = async (values: FormValues) => {
const response = await createRelease({
name: values.name,
});
if ('data' in response) {
// When the response returns an object with 'data', handle success
toggleNotification({
type: 'success',
message: formatMessage({
id: 'content-releases.modal.release-created-notification-success',
defaultMessage: 'Release created.',
}),
});
push(`/plugins/content-releases/${response.data.data.id}`);
} else if (isAxiosError(response.error)) {
// When the response returns an object with 'error', handle axios error
toggleNotification({
type: 'warning',
message: formatAPIError(response.error),
});
} else {
// Otherwise, the response returns an object with 'error', handle a generic error
toggleNotification({
type: 'warning',
message: formatMessage({ id: 'notification.error', defaultMessage: 'An error occurred' }),
});
}
};
return (
<ReleasesLayout onClickAddRelease={toggleAddReleaseDialog} totalReleases={totalReleases}>
<ReleasesLayout onClickAddRelease={toggleAddReleaseModal} totalReleases={totalReleases}>
<ContentLayout>
<>
<TabGroup
@ -266,7 +312,14 @@ const ReleasesPage = () => {
)}
</>
</ContentLayout>
{addReleaseDialogIsShown && <AddReleaseDialog handleClose={toggleAddReleaseDialog} />}
{releaseModalShown && (
<ReleaseModal
handleClose={toggleAddReleaseModal}
handleSubmit={handleAddRelease}
isLoading={isSubmittingForm}
initialValues={INITIAL_FORM_VALUES}
/>
)}
</ReleasesLayout>
);
};

View File

@ -1,9 +1,36 @@
import { render, screen } from '@tests/utils';
import { render, screen, server } from '@tests/utils';
import { rest } from 'msw';
import { ReleaseDetailsPage } from '../ReleaseDetailsPage';
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useParams: jest.fn().mockImplementation(() => ({ id: '1' })),
}));
jest.mock('@strapi/helper-plugin', () => ({
...jest.requireActual('@strapi/helper-plugin'),
// eslint-disable-next-line
CheckPermissions: ({ children }: { children: JSX.Element}) => <div>{children}</div>
}));
describe('Release details page', () => {
it('renders correctly the heading content', async () => {
server.use(
rest.put('/content-releases/1', (req, res, ctx) =>
res(
ctx.json({
data: {
id: 2,
name: 'Release title focus',
releasedAt: null,
createdAt: '2023-11-30T16:02:40.908Z',
updatedAt: '2023-12-01T11:12:04.441Z',
},
})
)
)
);
const { user } = render(<ReleaseDetailsPage />);
expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('Release title');
// if there are 0 entries

View File

@ -5,9 +5,9 @@ import { pluginId } from '../pluginId';
import { axiosBaseQuery } from './axios';
import type { CreateRelease, GetReleases } from '../../../shared/contracts/releases';
import type { CreateRelease, GetReleases, UpdateRelease } from '../../../shared/contracts/releases';
interface GetReleasesQueryParams {
export interface GetReleasesQueryParams {
page?: number;
pageSize?: number;
filters?: {
@ -104,6 +104,19 @@ const releaseApi = createApi({
},
invalidatesTags: ['Releases'],
}),
updateRelease: build.mutation<
void,
UpdateRelease.Request['params'] & UpdateRelease.Request['body']
>({
query({ id, ...data }) {
return {
url: `/content-releases/${id}`,
method: 'PUT',
data,
};
},
invalidatesTags: ['Releases'],
}),
createReleaseAction: build.mutation<
CreateReleaseAction.Response,
CreateReleaseAction.Request
@ -126,6 +139,7 @@ const {
useGetReleasesForEntryQuery,
useCreateReleaseMutation,
useCreateReleaseActionMutation,
useUpdateReleaseMutation,
} = releaseApi;
export {
@ -133,7 +147,6 @@ export {
useGetReleasesForEntryQuery,
useCreateReleaseMutation,
useCreateReleaseActionMutation,
useUpdateReleaseMutation,
releaseApi,
};
export type { GetReleasesQueryParams };

View File

@ -17,8 +17,9 @@
"header.actions.edit": "Edit",
"header.actions.delete": "Delete",
"header.actions.created": "Created",
"header.actions.created.description": "{number, plural, =0 {# days} one {# day} other {# days}} ago by {user}",
"header.actions.created.description": "{number, plural, =0 {# days} one {# day} other {# days}} ago by {createdBy}",
"modal.release-created-notification-success": "Release created",
"modal.release-updated-notification-success": "Release updated",
"modal.add-release-title": "New Release",
"modal.form.input.label.release-name": "Name",
"modal.form.button.submit": "Continue",