Merge branch 'main' into releases/4.17.0

This commit is contained in:
Josh 2024-01-10 13:55:25 +00:00
commit 91ffe59a86
21 changed files with 684 additions and 383 deletions

View File

@ -98,6 +98,7 @@ const AddActionToReleaseModal = ({
const { formatMessage } = useIntl();
const toggleNotification = useNotification();
const { formatAPIError } = useAPIErrorHandler();
const { modifiedData } = useCMEditViewDataManager();
// Get all 'pending' releases that do not have the entry attached
const response = useGetReleasesForEntryQuery({
@ -110,9 +111,11 @@ const AddActionToReleaseModal = ({
const [createReleaseAction, { isLoading }] = useCreateReleaseActionMutation();
const handleSubmit = async (values: FormValues) => {
const locale = modifiedData.locale as string | undefined;
const releaseActionEntry = {
contentType: contentTypeUid,
id: entryId,
locale,
};
const response = await createReleaseAction({
body: { type: values.type, entry: releaseActionEntry },

View File

@ -12,7 +12,7 @@ import { useDeleteReleaseActionMutation } from '../services/release';
const StyledMenuItem = styled(Menu.Item)`
&:hover {
background: transparent;
background: ${({ theme }) => theme.colors.danger100};
}
svg {
@ -100,11 +100,11 @@ export const ReleaseActionMenu = ({ releaseId, actionId }: ReleaseActionMenuProp
// @ts-expect-error See above
icon={<More />}
/>
{/*
{/*
TODO: Using Menu instead of SimpleMenu mainly because there is no positioning provided from the DS,
Refactor this once fixed in the DS
*/}
<Menu.Content top={1}>
<Menu.Content top={1} popoverPlacement="bottom-end">
<CheckPermissions permissions={PERMISSIONS.deleteAction}>
<StyledMenuItem color="danger600" onSelect={handleDeleteAction}>
<Flex gap={2}>

View File

@ -9,8 +9,10 @@ import {
} from '@strapi/design-system';
import { Formik, Form } from 'formik';
import { useIntl } from 'react-intl';
import { useLocation } from 'react-router-dom';
import { RELEASE_SCHEMA } from '../../../shared/validation-schemas';
import { pluginId } from '../pluginId';
export interface FormValues {
name: string;
@ -30,15 +32,21 @@ export const ReleaseModal = ({
isLoading = false,
}: ReleaseModalProps) => {
const { formatMessage } = useIntl();
const { pathname } = useLocation();
const isCreatingRelease = pathname === `/plugins/${pluginId}`;
return (
<ModalLayout onClose={handleClose} labelledBy="title">
<ModalHeader>
<Typography id="title" fontWeight="bold" textColor="neutral800">
{formatMessage({
id: 'content-releases.modal.add-release-title',
defaultMessage: 'New release',
})}
{formatMessage(
{
id: 'content-releases.modal.title',
defaultMessage:
'{isCreatingRelease, select, true {New release} other {Edit release}}',
},
{ isCreatingRelease: isCreatingRelease }
)}
</Typography>
</ModalHeader>
<Formik
@ -69,11 +77,19 @@ export const ReleaseModal = ({
</Button>
}
endActions={
<Button name="submit" loading={isLoading} disabled={!values.name} type="submit">
{formatMessage({
id: 'content-releases.modal.form.button.submit',
defaultMessage: 'Continue',
})}
<Button
name="submit"
loading={isLoading}
disabled={!values.name || values.name === initialValues.name}
type="submit"
>
{formatMessage(
{
id: 'content-releases.modal.form.button.submit',
defaultMessage: '{isCreatingRelease, select, true {Continue} other {Save}}',
},
{ isCreatingRelease: isCreatingRelease }
)}
</Button>
}
/>

View File

@ -1,18 +1,22 @@
import { within } from '@testing-library/react';
import { render, screen } from '@tests/utils';
import { MemoryRouter } from 'react-router-dom';
import { pluginId } from '../../pluginId';
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}
/>
<MemoryRouter initialEntries={[`/plugins/${pluginId}`]}>
<ReleaseModal
handleClose={handleCloseMocked}
handleSubmit={jest.fn()}
initialValues={{ name: '' }}
isLoading={false}
/>
</MemoryRouter>
);
const dialogContainer = screen.getByRole('dialog');
const dialogCancelButton = within(dialogContainer).getByRole('button', {
@ -35,7 +39,7 @@ describe('ReleaseModal', () => {
});
it('renders correctly the dialog content on update', async () => {
const handleCloseMocked = jest.fn();
render(
const { user } = render(
<ReleaseModal
handleClose={handleCloseMocked}
handleSubmit={jest.fn()}
@ -49,10 +53,18 @@ describe('ReleaseModal', () => {
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,
// disable the submit button when there are no changes inside the input
const dialogSaveButton = within(dialogContainer).getByRole('button', {
name: /save/i,
});
expect(dialogContinueButton).toBeEnabled();
expect(dialogSaveButton).toBeDisabled();
// change the input value and enable the submit button
await user.type(inputElement, 'new content');
expect(dialogSaveButton).toBeEnabled();
// change the input to an empty value and disable the submit button
await user.clear(inputElement);
expect(dialogSaveButton).toBeDisabled();
});
});

View File

@ -1,19 +1,19 @@
import { CheckPagePermissions } from '@strapi/helper-plugin';
import { Route, Switch } from 'react-router-dom';
import { PERMISSIONS } from '../constants';
import { pluginId } from '../pluginId';
import { ProtectedReleaseDetailsPage } from './ReleaseDetailsPage';
import { ProtectedReleasesPage } from './ReleasesPage';
import { ReleaseDetailsPage } from './ReleaseDetailsPage';
import { ReleasesPage } from './ReleasesPage';
export const App = () => {
return (
<Switch>
<Route exact path={`/plugins/${pluginId}`} component={ProtectedReleasesPage} />
<Route
exact
path={`/plugins/${pluginId}/:releaseId`}
component={ProtectedReleaseDetailsPage}
/>
</Switch>
<CheckPagePermissions permissions={PERMISSIONS.main}>
<Switch>
<Route exact path={`/plugins/${pluginId}`} component={ReleasesPage} />
<Route exact path={`/plugins/${pluginId}/:releaseId`} component={ReleaseDetailsPage} />
</Switch>
</CheckPagePermissions>
);
};

View File

@ -12,6 +12,10 @@ import {
Tr,
Td,
Typography,
Badge,
SingleSelect,
SingleSelectOption,
Icon,
} from '@strapi/design-system';
import { LinkButton } from '@strapi/design-system/v2';
import {
@ -28,11 +32,12 @@ import {
ConfirmDialog,
useRBAC,
} from '@strapi/helper-plugin';
import { ArrowLeft, More, Pencil, Trash } from '@strapi/icons';
import { ArrowLeft, CheckCircle, More, Pencil, Trash } from '@strapi/icons';
import { useIntl } from 'react-intl';
import { useParams, useHistory, Link as ReactRouterLink, Redirect } from 'react-router-dom';
import styled from 'styled-components';
import { ReleaseActionMenu } from '../components/ReleaseActionMenu';
import { ReleaseActionOptions } from '../components/ReleaseActionOptions';
import { ReleaseModal, FormValues } from '../components/ReleaseModal';
import { PERMISSIONS } from '../constants';
@ -47,7 +52,10 @@ import {
useDeleteReleaseMutation,
} from '../services/release';
import type { ReleaseAction } from '../../../shared/contracts/release-actions';
import type {
ReleaseAction,
ReleaseActionGroupBy,
} from '../../../shared/contracts/release-actions';
/* -------------------------------------------------------------------------------------------------
* ReleaseDetailsLayout
@ -113,6 +121,58 @@ const PopoverButton = ({ onClick, disabled, children }: PopoverButtonProps) => {
);
};
interface EntryValidationTextProps {
status: ReleaseAction['entry']['status'];
action: ReleaseAction['type'];
}
const EntryValidationText = ({ status, action }: EntryValidationTextProps) => {
const { formatMessage } = useIntl();
if (action == 'publish') {
return (
<Flex gap={2}>
<Icon color="success600" as={CheckCircle} />
{status === 'published' ? (
<Typography textColor="success600" fontWeight="bold">
{formatMessage({
id: 'content-releases.pages.ReleaseDetails.entry-validation.already-published',
defaultMessage: 'Already published',
})}
</Typography>
) : (
<Typography>
{formatMessage({
id: 'content-releases.pages.ReleaseDetails.entry-validation.ready-to-publish',
defaultMessage: 'Ready to publish',
})}
</Typography>
)}
</Flex>
);
}
return (
<Flex gap={2}>
<Icon color="success600" as={CheckCircle} />
{status === 'draft' ? (
<Typography textColor="success600" fontWeight="bold">
{formatMessage({
id: 'content-releases.pages.ReleaseDetails.entry-validation.already-unpublished',
defaultMessage: 'Already unpublished',
})}
</Typography>
) : (
<Typography>
{formatMessage({
id: 'content-releases.pages.ReleaseDetails.entry-validation.ready-to-unpublish',
defaultMessage: 'Ready to unpublish',
})}
</Typography>
)}
</Flex>
);
};
interface ReleaseDetailsLayoutProps {
toggleEditReleaseModal: () => void;
toggleWarningSubmit: () => void;
@ -210,7 +270,9 @@ export const ReleaseDetailsLayout = ({
}
const totalEntries = release.actions.meta.count || 0;
const createdBy = `${release.createdBy.firstname} ${release.createdBy.lastname}`;
const createdBy = release.createdBy.lastname
? `${release.createdBy.firstname} ${release.createdBy.lastname}`
: `${release.createdBy.firstname}`;
return (
<Main aria-busy={isLoadingDetails}>
@ -324,10 +386,32 @@ export const ReleaseDetailsLayout = ({
/* -------------------------------------------------------------------------------------------------
* ReleaseDetailsBody
* -----------------------------------------------------------------------------------------------*/
const GROUP_BY_OPTIONS = ['contentType', 'locale', 'action'] as const;
const getGroupByOptionLabel = (value: (typeof GROUP_BY_OPTIONS)[number]) => {
if (value === 'locale') {
return {
id: 'content-releases.pages.ReleaseDetails.groupBy.option.locales',
defaultMessage: 'Locales',
};
}
if (value === 'action') {
return {
id: 'content-releases.pages.ReleaseDetails.groupBy.option.actions',
defaultMessage: 'Actions',
};
}
return {
id: 'content-releases.pages.ReleaseDetails.groupBy.option.content-type',
defaultMessage: 'Content-Types',
};
};
const ReleaseDetailsBody = () => {
const { formatMessage } = useIntl();
const { releaseId } = useParams<{ releaseId: string }>();
const [{ query }] = useQueryParams<GetReleaseActionsQueryParams>();
const [{ query }, setQuery] = useQueryParams<GetReleaseActionsQueryParams>();
const toggleNotification = useNotification();
const { formatAPIError } = useAPIErrorHandler();
const {
@ -336,7 +420,9 @@ const ReleaseDetailsBody = () => {
isError: isReleaseError,
error: releaseError,
} = useGetReleaseQuery({ id: releaseId });
const release = releaseData?.data;
const selectedGroupBy = query?.groupBy || 'contentType';
const {
isLoading,
@ -390,7 +476,10 @@ const ReleaseDetailsBody = () => {
);
}
if (isError || isReleaseError || !release) {
const releaseActions = data?.data;
const releaseMeta = data?.meta;
if (isError || isReleaseError || !release || !releaseActions) {
const errorsArray = [];
if (releaseError) {
errorsArray.push({
@ -414,10 +503,7 @@ const ReleaseDetailsBody = () => {
);
}
const releaseActions = data?.data;
const releaseMeta = data?.meta;
if (!releaseActions || !releaseActions.length) {
if (Object.keys(releaseActions).length === 0) {
return (
<ContentLayout>
<NoContent
@ -449,94 +535,152 @@ const ReleaseDetailsBody = () => {
return (
<ContentLayout>
<Flex gap={4} direction="column" alignItems="stretch">
<Table.Root
rows={releaseActions.map((item) => ({
...item,
id: Number(item.entry.id),
}))}
colCount={releaseActions.length}
isLoading={isLoading}
isFetching={isFetching}
>
<Table.Content>
<Table.Head>
<Table.HeaderCell
fieldSchemaType="string"
label={formatMessage({
id: 'content-releases.page.ReleaseDetails.table.header.label.name',
defaultMessage: 'name',
})}
name="name"
/>
<Table.HeaderCell
fieldSchemaType="string"
label={formatMessage({
id: 'content-releases.page.ReleaseDetails.table.header.label.locale',
defaultMessage: 'locale',
})}
name="locale"
/>
<Table.HeaderCell
fieldSchemaType="string"
label={formatMessage({
id: 'content-releases.page.ReleaseDetails.table.header.label.content-type',
defaultMessage: 'content-type',
})}
name="content-type"
/>
<Table.HeaderCell
fieldSchemaType="string"
label={formatMessage({
id: 'content-releases.page.ReleaseDetails.table.header.label.action',
defaultMessage: 'action',
})}
name="action"
/>
</Table.Head>
<Table.LoadingBody />
<Table.Body>
{releaseActions.map(({ id, type, entry }) => (
<Tr key={id}>
<Td>
<Typography>{`${entry.contentType.mainFieldValue || entry.id}`}</Typography>
</Td>
<Td>
<Typography>{`${entry?.locale?.name ? entry.locale.name : '-'}`}</Typography>
</Td>
<Td>
<Typography>{entry.contentType.displayName || ''}</Typography>
</Td>
<Td>
{release.releasedAt ? (
<Typography>
{formatMessage(
{
id: 'content-releases.page.ReleaseDetails.table.action-published',
defaultMessage:
'This entry was <b>{isPublish, select, true {published} other {unpublished}}</b>.',
},
{
isPublish: type === 'publish',
b: (children: React.ReactNode) => (
<Typography fontWeight="bold">{children}</Typography>
),
}
<Flex gap={8} direction="column" alignItems="stretch">
<Flex>
<SingleSelect
aria-label={formatMessage({
id: 'content-releases.pages.ReleaseDetails.groupBy.label',
defaultMessage: 'Group by',
})}
customizeContent={(value) =>
formatMessage(
{
id: `content-releases.pages.ReleaseDetails.groupBy.label`,
defaultMessage: `Group by {groupBy}`,
},
{
groupBy: value,
}
)
}
value={formatMessage(getGroupByOptionLabel(selectedGroupBy))}
onChange={(value) => setQuery({ groupBy: value as ReleaseActionGroupBy })}
>
{GROUP_BY_OPTIONS.map((option) => (
<SingleSelectOption key={option} value={option}>
{formatMessage(getGroupByOptionLabel(option))}
</SingleSelectOption>
))}
</SingleSelect>
</Flex>
{Object.keys(releaseActions).map((key) => (
<Flex key={`releases-group-${key}`} gap={4} direction="column" alignItems="stretch">
<Flex>
<Badge>{key}</Badge>
</Flex>
<Table.Root
rows={releaseActions[key].map((item) => ({
...item,
id: Number(item.entry.id),
}))}
colCount={releaseActions[key].length}
isLoading={isLoading}
isFetching={isFetching}
>
<Table.Content>
<Table.Head>
<Table.HeaderCell
fieldSchemaType="string"
label={formatMessage({
id: 'content-releases.page.ReleaseDetails.table.header.label.name',
defaultMessage: 'name',
})}
name="name"
/>
<Table.HeaderCell
fieldSchemaType="string"
label={formatMessage({
id: 'content-releases.page.ReleaseDetails.table.header.label.locale',
defaultMessage: 'locale',
})}
name="locale"
/>
<Table.HeaderCell
fieldSchemaType="string"
label={formatMessage({
id: 'content-releases.page.ReleaseDetails.table.header.label.content-type',
defaultMessage: 'content-type',
})}
name="content-type"
/>
<Table.HeaderCell
fieldSchemaType="string"
label={formatMessage({
id: 'content-releases.page.ReleaseDetails.table.header.label.action',
defaultMessage: 'action',
})}
name="action"
/>
{!release.releasedAt && (
<Table.HeaderCell
fieldSchemaType="string"
label={formatMessage({
id: 'content-releases.page.ReleaseDetails.table.header.label.status',
defaultMessage: 'status',
})}
name="status"
/>
)}
</Table.Head>
<Table.LoadingBody />
<Table.Body>
{releaseActions[key].map(({ id, type, entry }) => (
<Tr key={id}>
<Td width={'25%'}>
<Typography ellipsis>{`${
entry.contentType.mainFieldValue || entry.id
}`}</Typography>
</Td>
<Td>
<Typography>{`${
entry?.locale?.name ? entry.locale.name : '-'
}`}</Typography>
</Td>
<Td>
<Typography>{entry.contentType.displayName || ''}</Typography>
</Td>
<Td>
{release.releasedAt ? (
<Typography>
{formatMessage(
{
id: 'content-releases.page.ReleaseDetails.table.action-published',
defaultMessage:
'This entry was <b>{isPublish, select, true {published} other {unpublished}}</b>.',
},
{
isPublish: type === 'publish',
b: (children: React.ReactNode) => (
<Typography fontWeight="bold">{children}</Typography>
),
}
)}
</Typography>
) : (
<ReleaseActionOptions
selected={type}
handleChange={(e) => handleChangeType(e, id)}
name={`release-action-${id}-type`}
/>
)}
</Typography>
) : (
<ReleaseActionOptions
selected={type}
handleChange={(e) => handleChangeType(e, id)}
name={`release-action-${id}-type`}
/>
)}
</Td>
</Tr>
))}
</Table.Body>
</Table.Content>
</Table.Root>
</Td>
{!release.releasedAt && (
<Td>
<EntryValidationText status={entry.status} action={type} />
</Td>
)}
<Td>
<Flex justifyContent="flex-end">
<ReleaseActionMenu releaseId={releaseId} actionId={id} />
</Flex>
</Td>
</Tr>
))}
</Table.Body>
</Table.Content>
</Table.Root>
</Flex>
))}
<Flex paddingTop={4} alignItems="flex-end" justifyContent="space-between">
<PageSizeURLQuery defaultValue={releaseMeta?.pagination?.pageSize.toString()} />
<PaginationURLQuery
@ -673,10 +817,4 @@ const ReleaseDetailsPage = () => {
);
};
const ProtectedReleaseDetailsPage = () => (
<CheckPermissions permissions={PERMISSIONS.main}>
<ReleaseDetailsPage />
</CheckPermissions>
);
export { ReleaseDetailsPage, ProtectedReleaseDetailsPage };
export { ReleaseDetailsPage };

View File

@ -349,10 +349,4 @@ const ReleasesPage = () => {
);
};
const ProtectedReleasesPage = () => (
<CheckPermissions permissions={PERMISSIONS.main}>
<ReleasesPage />
</CheckPermissions>
);
export { ReleasesPage, ProtectedReleasesPage };
export { ReleasesPage };

View File

@ -1,4 +1,5 @@
import { useRBAC } from '@strapi/helper-plugin';
import { within } from '@testing-library/react';
import { render, server, screen } from '@tests/utils';
import { rest } from 'msw';
@ -96,17 +97,21 @@ describe('Releases details page', () => {
// should show the entries
expect(
screen.getByText(
mockReleaseDetailsPageData.withActionsBodyData.data[0].entry.contentType.mainFieldValue
mockReleaseDetailsPageData.withActionsBodyData.data['Category'][0].entry.contentType
.mainFieldValue
)
).toBeInTheDocument();
expect(
screen.getByRole('gridcell', {
name: mockReleaseDetailsPageData.withActionsBodyData.data['Category'][0].entry.contentType
.displayName,
})
).toBeInTheDocument();
expect(
screen.getByText(
mockReleaseDetailsPageData.withActionsBodyData.data[0].entry.contentType.displayName
mockReleaseDetailsPageData.withActionsBodyData.data['Category'][0].entry.locale.name
)
).toBeInTheDocument();
expect(
screen.getByText(mockReleaseDetailsPageData.withActionsBodyData.data[0].entry.locale.name)
).toBeInTheDocument();
// There is one column with actions and the right one is checked
expect(screen.getByRole('radio', { name: 'publish' })).toBeChecked();
@ -143,8 +148,7 @@ describe('Releases details page', () => {
expect(publishButton).not.toBeInTheDocument();
expect(screen.queryByRole('radio', { name: 'publish' })).not.toBeInTheDocument();
const container = screen.getByText(/This entry was/);
expect(container.querySelector('span')).toHaveTextContent('published');
expect(screen.getByRole('gridcell', { name: /This entry was published/i })).toBeInTheDocument();
});
it('renders the details page with the delete and edit buttons disabled', async () => {
@ -187,4 +191,67 @@ describe('Releases details page', () => {
const deleteButton = screen.getByRole('button', { name: 'Delete' });
expect(deleteButton).toBeDisabled();
});
it('renders as many tables as there are in the response', async () => {
server.use(
rest.get('/content-releases/:releaseId', (req, res, ctx) =>
res(ctx.json(mockReleaseDetailsPageData.withActionsHeaderData))
)
);
server.use(
rest.get('/content-releases/:releaseId/actions', (req, res, ctx) =>
res(ctx.json(mockReleaseDetailsPageData.withMultipleActionsBodyData))
)
);
render(<ReleaseDetailsPage />, {
initialEntries: [{ pathname: `/content-releases/1` }],
});
const releaseTitle = await screen.findByText(
mockReleaseDetailsPageData.withActionsHeaderData.data.name
);
expect(releaseTitle).toBeInTheDocument();
const tables = screen.getAllByRole('grid');
expect(tables).toHaveLength(2);
});
it('show the right status based on the action and status', async () => {
server.use(
rest.get('/content-releases/:releaseId', (req, res, ctx) =>
res(ctx.json(mockReleaseDetailsPageData.withActionsHeaderData))
)
);
server.use(
rest.get('/content-releases/:releaseId/actions', (req, res, ctx) =>
res(ctx.json(mockReleaseDetailsPageData.withMultipleActionsBodyData))
)
);
render(<ReleaseDetailsPage />, {
initialEntries: [{ pathname: `/content-releases/1` }],
});
const releaseTitle = await screen.findByText(
mockReleaseDetailsPageData.withActionsHeaderData.data.name
);
expect(releaseTitle).toBeInTheDocument();
const cat1Row = screen.getByRole('row', { name: /cat1/i });
expect(within(cat1Row).getByRole('gridcell', { name: 'Ready to publish' })).toBeInTheDocument();
const cat2Row = screen.getByRole('row', { name: /cat2/i });
expect(
within(cat2Row).getByRole('gridcell', { name: 'Ready to unpublish' })
).toBeInTheDocument();
const add1Row = screen.getByRole('row', { name: /add1/i });
expect(
within(add1Row).getByRole('gridcell', { name: 'Already published' })
).toBeInTheDocument();
});
});

View File

@ -93,26 +93,105 @@ const PUBLISHED_RELEASE_WITH_ACTIONS_HEADER_MOCK_DATA = {
* RELEASE_WITH_ACTIONS_BODY_MOCK_DATA
* -----------------------------------------------------------------------------------------------*/
const RELEASE_WITH_ACTIONS_BODY_MOCK_DATA = {
data: [
{
id: 3,
type: 'publish',
contentType: 'api::category.category',
createdAt: '2023-12-05T09:03:57.155Z',
updatedAt: '2023-12-05T09:03:57.155Z',
entry: {
id: 1,
contentType: {
displayName: 'Category',
mainFieldValue: 'cat1',
},
locale: {
name: 'English (en)',
code: 'en',
data: {
Category: [
{
id: 3,
type: 'publish',
contentType: 'api::category.category',
createdAt: '2023-12-05T09:03:57.155Z',
updatedAt: '2023-12-05T09:03:57.155Z',
entry: {
id: 1,
contentType: {
displayName: 'Category',
mainFieldValue: 'cat1',
},
locale: {
name: 'English (en)',
code: 'en',
},
},
},
],
},
meta: {
pagination: {
page: 1,
pageSize: 10,
total: 1,
pageCount: 1,
},
],
},
};
/* -------------------------------------------------------------------------------------------------
* RELEASE_WITH_MULTIPLE_ACTIONS_BODY_MOCK_DATA
* -----------------------------------------------------------------------------------------------*/
const RELEASE_WITH_MULTIPLE_ACTIONS_BODY_MOCK_DATA = {
data: {
Category: [
{
id: 3,
type: 'publish',
contentType: 'api::category.category',
createdAt: '2023-12-05T09:03:57.155Z',
updatedAt: '2023-12-05T09:03:57.155Z',
entry: {
id: 1,
contentType: {
displayName: 'Category',
mainFieldValue: 'cat1',
},
locale: {
name: 'English (en)',
code: 'en',
},
status: 'draft',
},
},
{
id: 4,
type: 'unpublish',
contentType: 'api::category.category',
createdAt: '2023-12-05T09:03:57.155Z',
updatedAt: '2023-12-05T09:03:57.155Z',
entry: {
id: 2,
contentType: {
displayName: 'Category',
mainFieldValue: 'cat2',
},
locale: {
name: 'English (en)',
code: 'en',
},
status: 'published',
},
},
],
Address: [
{
id: 5,
type: 'publish',
contentType: 'api::address.address',
createdAt: '2023-12-05T09:03:57.155Z',
updatedAt: '2023-12-05T09:03:57.155Z',
entry: {
id: 1,
contentType: {
displayName: 'Address',
mainFieldValue: 'add1',
},
locale: {
name: 'English (en)',
code: 'en',
},
status: 'published',
},
},
],
},
meta: {
pagination: {
page: 1,
@ -128,6 +207,7 @@ const mockReleaseDetailsPageData = {
noActionsBodyData: RELEASE_NO_ACTIONS_BODY_MOCK_DATA,
withActionsHeaderData: RELEASE_WITH_ACTIONS_HEADER_MOCK_DATA,
withActionsBodyData: RELEASE_WITH_ACTIONS_BODY_MOCK_DATA,
withMultipleActionsBodyData: RELEASE_WITH_MULTIPLE_ACTIONS_BODY_MOCK_DATA,
withActionsAndPublishedHeaderData: PUBLISHED_RELEASE_WITH_ACTIONS_HEADER_MOCK_DATA,
} as const;

View File

@ -11,6 +11,7 @@ import { axiosBaseQuery } from './axios';
import type {
GetReleaseActions,
UpdateReleaseAction,
ReleaseActionGroupBy,
} from '../../../shared/contracts/release-actions';
import type {
CreateRelease,
@ -36,6 +37,7 @@ export interface GetReleasesQueryParams {
export interface GetReleaseActionsQueryParams {
page?: number;
pageSize?: number;
groupBy?: ReleaseActionGroupBy;
}
type GetReleasesTabResponse = GetReleases.Response & {
@ -133,25 +135,16 @@ const releaseApi = createApi({
GetReleaseActions.Response,
GetReleaseActions.Request['params'] & GetReleaseActions.Request['query']
>({
query({ releaseId, page, pageSize }) {
query({ releaseId, ...params }) {
return {
url: `/content-releases/${releaseId}/actions`,
method: 'GET',
config: {
params: {
page,
pageSize,
},
params,
},
};
},
providesTags: (result, error, arg) =>
result
? [
...result.data.map(({ id }) => ({ type: 'ReleaseAction' as const, id })),
{ type: 'ReleaseAction', id: 'LIST' },
]
: [{ type: 'ReleaseAction', id: 'LIST' }],
providesTags: [{ type: 'ReleaseAction', id: 'LIST' }],
}),
createRelease: build.mutation<CreateRelease.Response, CreateRelease.Request['body']>({
query(data) {
@ -203,9 +196,7 @@ const releaseApi = createApi({
data: body,
};
},
invalidatesTags: (result, error, arg) => [
{ type: 'ReleaseAction', id: arg.params.actionId },
],
invalidatesTags: () => [{ type: 'ReleaseAction', id: 'LIST' }],
}),
deleteReleaseAction: build.mutation<
DeleteReleaseAction.Response,

View File

@ -26,9 +26,9 @@
"header.actions.created.description": " by {createdBy}",
"modal.release-created-notification-success": "Release created",
"modal.release-updated-notification-success": "Release updated",
"modal.add-release-title": "New Release",
"modal.title": "{isCreatingRelease, select, true {New release} other {Edit release}}",
"modal.form.input.label.release-name": "Name",
"modal.form.button.submit": "Continue",
"modal.form.button.submit": "{isCreatingRelease, select, true {Continue} other {Save}}",
"pages.Details.header-subtitle": "{number, plural, =0 {No entries} one {# entry} other {# entries}}",
"pages.Releases.tab-group.label": "Releases list",
"pages.Releases.tab.pending": "Pending",
@ -40,10 +40,19 @@
"page.ReleaseDetails.table.header.label.locale": "locale",
"page.ReleaseDetails.table.header.label.content-type": "content-type",
"page.ReleaseDetails.table.header.label.action": "action",
"content-releases.page.ReleaseDetails.table.header.label.status": "status",
"page.ReleaseDetails.table.action-published": "This entry was <b>{isPublish, select, true {published} other {unpublished}}</b>.",
"pages.ReleaseDetails.publish-notification-success": "Release was published successfully.",
"dialog.confirmation-message": "Are you sure you want to delete this release?",
"page.Details.button.openContentManager": "Open the Content Manager",
"pages.Releases.notification.error.title": "Your request could not be processed.",
"pages.Releases.notification.error.message": "Please try again or open another release."
"pages.Releases.notification.error.message": "Please try again or open another release.",
"pages.ReleaseDetails.groupBy.label": "Group by {groupBy}",
"pages.ReleaseDetails.entry-validation.already-published": "Already published",
"pages.ReleaseDetails.entry-validation.ready-to-publish": "Ready to publish",
"pages.ReleaseDetails.entry-validation.already-unpublished": "Already unpublished",
"pages.ReleaseDetails.entry-validation.ready-to-unpublish": "Ready to unpublish",
"pages.ReleaseDetails.groupBy.option.content-type": "Content-Types",
"pages.ReleaseDetails.groupBy.option.locales": "Locales",
"pages.ReleaseDetails.groupBy.option.actions": "Actions"
}

View File

@ -63,6 +63,7 @@
"@strapi/utils": "4.17.0",
"axios": "1.6.0",
"formik": "2.4.0",
"lodash": "4.17.21",
"react-intl": "6.4.1",
"react-redux": "8.1.1",
"yup": "0.32.9"

View File

@ -33,6 +33,9 @@ export default {
type: 'string',
required: true,
},
locale: {
type: 'string',
},
release: {
type: 'relation',
relation: 'manyToOne',

View File

@ -112,98 +112,4 @@ describe('Release Action controller', () => {
);
});
});
describe('findMany', () => {
it('should return the data for an entry', async () => {
mockFindActions.mockResolvedValueOnce({
results: [
{
id: 1,
contentType: 'api::contentTypeA.contentTypeA',
entry: { id: 1, name: 'test 1', locale: 'en' },
},
{
id: 2,
contentType: 'api::contentTypeB.contentTypeB',
entry: { id: 2, name: 'test 2', locale: 'fr' },
},
],
pagination: {},
});
global.strapi = {
plugins: {
// @ts-expect-error Ignore missing properties
i18n: {
services: {
locales: {
find: jest.fn().mockReturnValue([
{
id: 1,
name: 'English (en)',
code: 'en',
},
{
id: 2,
name: 'French (fr)',
code: 'fr',
},
]),
},
},
},
},
// @ts-expect-error Ignore missing properties
admin: {
services: {
permission: {
createPermissionsManager: jest.fn(() => ({
ability: {
can: jest.fn(),
},
validateQuery: jest.fn(),
sanitizeQuery: jest.fn(() => ctx.query),
})),
},
},
},
};
const ctx = {
state: {
userAbility: {},
},
params: {
releaseId: 1,
},
query: {},
};
// @ts-expect-error Ignore missing properties
await releaseActionController.findMany(ctx);
// @ts-expect-error Ignore missing properties
expect(ctx.body.data[0].entry).toEqual({
id: 1,
contentType: {
displayName: 'contentTypeA',
mainFieldValue: 'test 1',
},
locale: {
code: 'en',
name: 'English (en)',
},
});
// @ts-expect-error Ignore missing properties
expect(ctx.body.data[1].entry).toEqual({
id: 2,
contentType: {
displayName: 'contentTypeB',
mainFieldValue: 'test 2',
},
locale: {
code: 'fr',
name: 'French (fr)',
},
});
});
});
});

View File

@ -1,5 +1,4 @@
import type Koa from 'koa';
import { Entity } from '../../../shared/types';
import {
validateReleaseAction,
@ -8,22 +7,12 @@ import {
import type {
CreateReleaseAction,
GetReleaseActions,
ReleaseAction,
UpdateReleaseAction,
DeleteReleaseAction,
} from '../../../shared/contracts/release-actions';
import { getService } from '../utils';
import { RELEASE_ACTION_MODEL_UID } from '../constants';
interface Locale extends Entity {
name: string;
code: string;
}
type LocaleDictionary = {
[key: Locale['code']]: Pick<Locale, 'name' | 'code'>;
};
const releaseActionController = {
async create(ctx: Koa.Context) {
const releaseId: CreateReleaseAction.Request['params']['releaseId'] = ctx.params.releaseId;
@ -38,6 +27,7 @@ const releaseActionController = {
data: releaseAction,
};
},
async findMany(ctx: Koa.Context) {
const releaseId: GetReleaseActions.Request['params']['releaseId'] = ctx.params.releaseId;
const permissionsManager = strapi.admin.services.permission.createPermissionsManager({
@ -47,36 +37,14 @@ const releaseActionController = {
const query = await permissionsManager.sanitizeQuery(ctx.query);
const releaseService = getService('release', { strapi });
const { results, pagination } = await releaseService.findActions(releaseId, query);
const allReleaseContentTypesDictionary = await releaseService.getContentTypesDataForActions(
releaseId
);
const allLocales: Locale[] = await strapi.plugin('i18n').service('locales').find();
const allLocalesDictionary = allLocales.reduce<LocaleDictionary>((acc, locale) => {
acc[locale.code] = { name: locale.name, code: locale.code };
return acc;
}, {});
const data = results.map((action: ReleaseAction) => {
const { mainField, displayName } = allReleaseContentTypesDictionary[action.contentType];
return {
...action,
entry: {
id: action.entry.id,
contentType: {
displayName,
mainFieldValue: action.entry[mainField],
},
locale: allLocalesDictionary[action.entry.locale],
},
};
const { results, pagination } = await releaseService.findActions(releaseId, {
sort: query.groupBy === 'action' ? 'type' : query.groupBy,
...query,
});
const groupedData = await releaseService.groupActions(results, query.groupBy);
ctx.body = {
data,
data: groupedData,
meta: {
pagination,
},

View File

@ -205,4 +205,94 @@ describe('release service', () => {
expect(() => releaseService.delete(1)).rejects.toThrow('Release already published');
});
});
describe('groupActions', () => {
it('should return the data grouped by contentType', async () => {
const strapiMock = {
...baseStrapiMock,
plugin: jest.fn().mockReturnValue({
service: jest.fn().mockReturnValue({
find: jest.fn().mockReturnValue([
{ name: 'English (en)', code: 'en' },
{ name: 'French (fr)', code: 'fr' },
]),
}),
}),
};
const mockActions = [
{
id: 1,
contentType: 'api::contentTypeA.contentTypeA',
locale: 'en',
entry: { id: 1, name: 'test 1', publishedAt: '2021-01-01' },
},
{
id: 2,
contentType: 'api::contentTypeB.contentTypeB',
locale: 'fr',
entry: { id: 2, name: 'test 2', publishedAt: null },
},
];
// @ts-expect-error Ignore missing properties
const releaseService = createReleaseService({ strapi: strapiMock });
// Mock getContentTypesDataForActions inside the release service
releaseService.getContentTypesDataForActions = jest.fn().mockReturnValue({
'api::contentTypeA.contentTypeA': {
mainField: 'name',
displayName: 'contentTypeA',
},
'api::contentTypeB.contentTypeB': {
mainField: 'name',
displayName: 'contentTypeB',
},
});
// @ts-expect-error ignore missing properties
const groupedData = await releaseService.groupActions(mockActions, 'contentType');
expect(groupedData).toEqual({
contentTypeA: [
{
id: 1,
locale: 'en',
contentType: 'api::contentTypeA.contentTypeA',
entry: {
id: 1,
contentType: {
displayName: 'contentTypeA',
mainFieldValue: 'test 1',
},
locale: {
code: 'en',
name: 'English (en)',
},
status: 'published',
},
},
],
contentTypeB: [
{
id: 2,
locale: 'fr',
contentType: 'api::contentTypeB.contentTypeB',
entry: {
id: 2,
contentType: {
displayName: 'contentTypeB',
mainFieldValue: 'test 2',
},
locale: {
code: 'fr',
name: 'French (fr)',
},
status: 'draft',
},
},
],
});
});
});
});

View File

@ -1,5 +1,8 @@
import { setCreatorFields, errors } from '@strapi/utils';
import type { LoadedStrapi, EntityService, UID } from '@strapi/types';
import _ from 'lodash/fp';
import { RELEASE_ACTION_MODEL_UID, RELEASE_MODEL_UID } from '../constants';
import type {
GetReleases,
@ -17,10 +20,33 @@ import type {
ReleaseAction,
UpdateReleaseAction,
DeleteReleaseAction,
ReleaseActionGroupBy,
} from '../../../shared/contracts/release-actions';
import type { UserInfo } from '../../../shared/types';
import type { Entity, UserInfo } from '../../../shared/types';
import { getService } from '../utils';
interface Locale extends Entity {
name: string;
code: string;
}
type LocaleDictionary = {
[key: Locale['code']]: Pick<Locale, 'name' | 'code'>;
};
const getGroupName = (queryValue?: ReleaseActionGroupBy) => {
switch (queryValue) {
case 'contentType':
return 'entry.contentType.displayName';
case 'action':
return 'type';
case 'locale':
return _.getOr('No locale', 'entry.locale.name');
default:
return 'entry.contentType.displayName';
}
};
const createReleaseService = ({ strapi }: { strapi: LoadedStrapi }) => ({
async create(releaseData: CreateRelease.Request['body'], { user }: { user: UserInfo }) {
const releaseWithCreatorFields = await setCreatorFields({ user })(releaseData);
@ -166,6 +192,7 @@ const createReleaseService = ({ strapi }: { strapi: LoadedStrapi }) => ({
data: {
type,
contentType: entry.contentType,
locale: entry.locale,
entry: {
id: entry.id,
__type: entry.contentType,
@ -181,9 +208,11 @@ const createReleaseService = ({ strapi }: { strapi: LoadedStrapi }) => ({
releaseId: GetReleaseActions.Request['params']['releaseId'],
query?: GetReleaseActions.Request['query']
) {
const result = await strapi.entityService.findOne(RELEASE_MODEL_UID, releaseId);
const release = await strapi.entityService.findOne(RELEASE_MODEL_UID, releaseId, {
fields: ['id'],
});
if (!result) {
if (!release) {
throw new errors.NotFoundError(`No release found for id ${releaseId}`);
}
@ -202,26 +231,46 @@ const createReleaseService = ({ strapi }: { strapi: LoadedStrapi }) => ({
return strapi.entityService.count(RELEASE_ACTION_MODEL_UID, query);
},
async getAllContentTypeUids(releaseId: Release['id']) {
const contentTypesFromReleaseActions: { contentType: UID.ContentType }[] = await strapi.db
.queryBuilder(RELEASE_ACTION_MODEL_UID)
.select('content_type')
.where({
$and: [
{
release: releaseId,
},
],
})
.groupBy('content_type')
.execute();
async groupActions(actions: ReleaseAction[], groupBy: ReleaseActionGroupBy) {
const contentTypeUids = actions.reduce<ReleaseAction['contentType'][]>((acc, action) => {
if (!acc.includes(action.contentType)) {
acc.push(action.contentType);
}
return contentTypesFromReleaseActions.map(({ contentType: contentTypeUid }) => contentTypeUid);
return acc;
}, []);
const allReleaseContentTypesDictionary = await this.getContentTypesDataForActions(
contentTypeUids
);
const allLocales: Locale[] = await strapi.plugin('i18n').service('locales').find();
const allLocalesDictionary = allLocales.reduce<LocaleDictionary>((acc, locale) => {
acc[locale.code] = { name: locale.name, code: locale.code };
return acc;
}, {});
const formattedData = actions.map((action: ReleaseAction) => {
const { mainField, displayName } = allReleaseContentTypesDictionary[action.contentType];
return {
...action,
entry: {
id: action.entry.id,
contentType: {
displayName,
mainFieldValue: action.entry[mainField],
},
locale: action.locale ? allLocalesDictionary[action.locale] : null,
status: action.entry.publishedAt ? 'published' : 'draft',
},
};
});
const groupName = getGroupName(groupBy);
return _.groupBy(groupName)(formattedData);
},
async getContentTypesDataForActions(releaseId: Release['id']) {
const contentTypesUids = await this.getAllContentTypeUids(releaseId);
async getContentTypesDataForActions(contentTypesUids: ReleaseAction['contentType'][]) {
const contentManagerContentTypeService = strapi
.plugin('content-manager')
.service('content-types');

View File

@ -8,7 +8,8 @@ type ReleaseActionEntry = Entity & {
// Entity attributes
[key: string]: Attribute.Any;
} & {
locale: string;
locale?: string;
status: 'published' | 'draft';
};
type ReleaseActionEntryData = {
@ -21,12 +22,14 @@ type ReleaseActionEntryData = {
mainFieldValue?: string;
displayName: string;
};
status: 'published' | 'draft';
};
export interface ReleaseAction extends Entity {
type: 'publish' | 'unpublish';
entry: ReleaseActionEntry;
contentType: Common.UID.ContentType;
locale?: string;
release: Release;
}
@ -42,6 +45,7 @@ export declare namespace CreateReleaseAction {
type: ReleaseAction['type'];
entry: {
id: ReleaseActionEntry['id'];
locale?: ReleaseActionEntry['locale'];
contentType: Common.UID.ContentType;
};
};
@ -56,16 +60,22 @@ export declare namespace CreateReleaseAction {
/**
* GET /content-releases/:id/actions - Get all release actions
*/
export type ReleaseActionGroupBy = 'contentType' | 'action' | 'locale';
export declare namespace GetReleaseActions {
export interface Request {
params: {
releaseId: Release['id'];
};
query?: Partial<Pick<Pagination, 'page' | 'pageSize'>>;
query?: Partial<Pick<Pagination, 'page' | 'pageSize'>> & {
groupBy?: ReleaseActionGroupBy;
};
}
export interface Response {
data: Array<ReleaseAction & { entry: ReleaseActionEntryData }>;
data: {
[key: string]: Array<ReleaseAction & { entry: ReleaseActionEntryData }>;
};
meta: {
pagination: Pagination;
};

View File

@ -381,7 +381,8 @@ class Strapi implements StrapiI {
numberOfComponents: _.size(this.components),
numberOfDynamicZones: getNumberOfDynamicZones(),
numberOfCustomControllers: Object.values<Common.Controller>(this.controllers).filter(
factories.isCustomController
// TODO: Fix this at the content API loader level to prevent future types issues
(controller) => controller !== undefined && factories.isCustomController(controller)
).length,
environment: this.config.environment,
// TODO: to add back

View File

@ -43,8 +43,8 @@ const createCoreController = <
Object.setPrototypeOf(userCtrl, baseController);
const isCustomController = typeof cfg !== 'undefined';
if (isCustomController) {
const isCustom = typeof cfg !== 'undefined';
if (isCustom) {
Object.defineProperty(userCtrl, symbols.CustomController, {
writable: false,
configurable: false,

View File

@ -3298,15 +3298,6 @@ __metadata:
languageName: node
linkType: hard
"@floating-ui/core@npm:^1.4.1":
version: 1.4.1
resolution: "@floating-ui/core@npm:1.4.1"
dependencies:
"@floating-ui/utils": "npm:^0.1.1"
checksum: 2a2dd8a2ae443e63cb9c822785891b1194ad3a402b8252054a3c238763eab86a2f09ab89096fa7d1667e3cb7d2ff2f28b7ab07d5e5ee56544e825e5bd4665570
languageName: node
linkType: hard
"@floating-ui/core@npm:^1.5.3":
version: 1.5.3
resolution: "@floating-ui/core@npm:1.5.3"
@ -3316,17 +3307,7 @@ __metadata:
languageName: node
linkType: hard
"@floating-ui/dom@npm:^1.0.1, @floating-ui/dom@npm:^1.5.1":
version: 1.5.1
resolution: "@floating-ui/dom@npm:1.5.1"
dependencies:
"@floating-ui/core": "npm:^1.4.1"
"@floating-ui/utils": "npm:^0.1.1"
checksum: 3af542d549e394feb0c74f39ef01d87debb5295b49b8852ad481a055503e5dc61768880710c83de6e49a2c100cad8671298d3c73293cb63a13e23068f0612224
languageName: node
linkType: hard
"@floating-ui/dom@npm:^1.5.4":
"@floating-ui/dom@npm:^1.0.1, @floating-ui/dom@npm:^1.5.4":
version: 1.5.4
resolution: "@floating-ui/dom@npm:1.5.4"
dependencies:
@ -3336,19 +3317,7 @@ __metadata:
languageName: node
linkType: hard
"@floating-ui/react-dom@npm:^2.0.0":
version: 2.0.4
resolution: "@floating-ui/react-dom@npm:2.0.4"
dependencies:
"@floating-ui/dom": "npm:^1.5.1"
peerDependencies:
react: ">=16.8.0"
react-dom: ">=16.8.0"
checksum: 4240a718502c797fd2e174cd06dcd7321a6eda9c8966dbaf61864b9e16445e95649a59bfe7c19ee13f68c11f3693724d7970c7e618089a3d3915bd343639cfae
languageName: node
linkType: hard
"@floating-ui/react-dom@npm:^2.0.5":
"@floating-ui/react-dom@npm:^2.0.0, @floating-ui/react-dom@npm:^2.0.5":
version: 2.0.5
resolution: "@floating-ui/react-dom@npm:2.0.5"
dependencies:
@ -3360,13 +3329,6 @@ __metadata:
languageName: node
linkType: hard
"@floating-ui/utils@npm:^0.1.1":
version: 0.1.1
resolution: "@floating-ui/utils@npm:0.1.1"
checksum: ba1a6d073f8af4290f9d0ecf2ca73f97a742ed9bae060bf4aec604db5642a6cc7aa38041c2c1a1ffb0dd1c99549bbfde3dc61202ad7cb91a963d2b2eea747719
languageName: node
linkType: hard
"@floating-ui/utils@npm:^0.2.0":
version: 0.2.1
resolution: "@floating-ui/utils@npm:0.2.1"
@ -7698,6 +7660,7 @@ __metadata:
axios: "npm:1.6.0"
formik: "npm:2.4.0"
koa: "npm:2.13.4"
lodash: "npm:4.17.21"
msw: "npm:1.3.0"
react: "npm:^18.2.0"
react-dom: "npm:^18.2.0"