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

View File

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

View File

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

View File

@ -1,18 +1,22 @@
import { within } from '@testing-library/react'; import { within } from '@testing-library/react';
import { render, screen } from '@tests/utils'; import { render, screen } from '@tests/utils';
import { MemoryRouter } from 'react-router-dom';
import { pluginId } from '../../pluginId';
import { ReleaseModal } from '../ReleaseModal'; import { ReleaseModal } from '../ReleaseModal';
describe('ReleaseModal', () => { describe('ReleaseModal', () => {
it('renders correctly the dialog content on create', async () => { it('renders correctly the dialog content on create', async () => {
const handleCloseMocked = jest.fn(); const handleCloseMocked = jest.fn();
const { user } = render( const { user } = render(
<ReleaseModal <MemoryRouter initialEntries={[`/plugins/${pluginId}`]}>
handleClose={handleCloseMocked} <ReleaseModal
handleSubmit={jest.fn()} handleClose={handleCloseMocked}
initialValues={{ name: '' }} handleSubmit={jest.fn()}
isLoading={false} initialValues={{ name: '' }}
/> isLoading={false}
/>
</MemoryRouter>
); );
const dialogContainer = screen.getByRole('dialog'); const dialogContainer = screen.getByRole('dialog');
const dialogCancelButton = within(dialogContainer).getByRole('button', { const dialogCancelButton = within(dialogContainer).getByRole('button', {
@ -35,7 +39,7 @@ describe('ReleaseModal', () => {
}); });
it('renders correctly the dialog content on update', async () => { it('renders correctly the dialog content on update', async () => {
const handleCloseMocked = jest.fn(); const handleCloseMocked = jest.fn();
render( const { user } = render(
<ReleaseModal <ReleaseModal
handleClose={handleCloseMocked} handleClose={handleCloseMocked}
handleSubmit={jest.fn()} handleSubmit={jest.fn()}
@ -49,10 +53,18 @@ describe('ReleaseModal', () => {
const inputElement = within(dialogContainer).getByRole('textbox', { name: /name/i }); const inputElement = within(dialogContainer).getByRole('textbox', { name: /name/i });
expect(inputElement).toHaveValue('title'); expect(inputElement).toHaveValue('title');
// enable the submit button when there is content inside the input // disable the submit button when there are no changes inside the input
const dialogContinueButton = within(dialogContainer).getByRole('button', { const dialogSaveButton = within(dialogContainer).getByRole('button', {
name: /continue/i, 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 { Route, Switch } from 'react-router-dom';
import { PERMISSIONS } from '../constants';
import { pluginId } from '../pluginId'; import { pluginId } from '../pluginId';
import { ProtectedReleaseDetailsPage } from './ReleaseDetailsPage'; import { ReleaseDetailsPage } from './ReleaseDetailsPage';
import { ProtectedReleasesPage } from './ReleasesPage'; import { ReleasesPage } from './ReleasesPage';
export const App = () => { export const App = () => {
return ( return (
<Switch> <CheckPagePermissions permissions={PERMISSIONS.main}>
<Route exact path={`/plugins/${pluginId}`} component={ProtectedReleasesPage} /> <Switch>
<Route <Route exact path={`/plugins/${pluginId}`} component={ReleasesPage} />
exact <Route exact path={`/plugins/${pluginId}/:releaseId`} component={ReleaseDetailsPage} />
path={`/plugins/${pluginId}/:releaseId`} </Switch>
component={ProtectedReleaseDetailsPage} </CheckPagePermissions>
/>
</Switch>
); );
}; };

View File

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

View File

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

View File

@ -1,4 +1,5 @@
import { useRBAC } from '@strapi/helper-plugin'; import { useRBAC } from '@strapi/helper-plugin';
import { within } from '@testing-library/react';
import { render, server, screen } from '@tests/utils'; import { render, server, screen } from '@tests/utils';
import { rest } from 'msw'; import { rest } from 'msw';
@ -96,17 +97,21 @@ describe('Releases details page', () => {
// should show the entries // should show the entries
expect( expect(
screen.getByText( screen.getByText(
mockReleaseDetailsPageData.withActionsBodyData.data[0].entry.contentType.mainFieldValue mockReleaseDetailsPageData.withActionsBodyData.data['Category'][0].entry.contentType
.mainFieldValue
) )
).toBeInTheDocument(); ).toBeInTheDocument();
expect(
screen.getByRole('gridcell', {
name: mockReleaseDetailsPageData.withActionsBodyData.data['Category'][0].entry.contentType
.displayName,
})
).toBeInTheDocument();
expect( expect(
screen.getByText( screen.getByText(
mockReleaseDetailsPageData.withActionsBodyData.data[0].entry.contentType.displayName mockReleaseDetailsPageData.withActionsBodyData.data['Category'][0].entry.locale.name
) )
).toBeInTheDocument(); ).toBeInTheDocument();
expect(
screen.getByText(mockReleaseDetailsPageData.withActionsBodyData.data[0].entry.locale.name)
).toBeInTheDocument();
// There is one column with actions and the right one is checked // There is one column with actions and the right one is checked
expect(screen.getByRole('radio', { name: 'publish' })).toBeChecked(); expect(screen.getByRole('radio', { name: 'publish' })).toBeChecked();
@ -143,8 +148,7 @@ describe('Releases details page', () => {
expect(publishButton).not.toBeInTheDocument(); expect(publishButton).not.toBeInTheDocument();
expect(screen.queryByRole('radio', { name: 'publish' })).not.toBeInTheDocument(); expect(screen.queryByRole('radio', { name: 'publish' })).not.toBeInTheDocument();
const container = screen.getByText(/This entry was/); expect(screen.getByRole('gridcell', { name: /This entry was published/i })).toBeInTheDocument();
expect(container.querySelector('span')).toHaveTextContent('published');
}); });
it('renders the details page with the delete and edit buttons disabled', async () => { 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' }); const deleteButton = screen.getByRole('button', { name: 'Delete' });
expect(deleteButton).toBeDisabled(); 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 * RELEASE_WITH_ACTIONS_BODY_MOCK_DATA
* -----------------------------------------------------------------------------------------------*/ * -----------------------------------------------------------------------------------------------*/
const RELEASE_WITH_ACTIONS_BODY_MOCK_DATA = { const RELEASE_WITH_ACTIONS_BODY_MOCK_DATA = {
data: [ data: {
{ Category: [
id: 3, {
type: 'publish', id: 3,
contentType: 'api::category.category', type: 'publish',
createdAt: '2023-12-05T09:03:57.155Z', contentType: 'api::category.category',
updatedAt: '2023-12-05T09:03:57.155Z', createdAt: '2023-12-05T09:03:57.155Z',
entry: { updatedAt: '2023-12-05T09:03:57.155Z',
id: 1, entry: {
contentType: { id: 1,
displayName: 'Category', contentType: {
mainFieldValue: 'cat1', displayName: 'Category',
}, mainFieldValue: 'cat1',
locale: { },
name: 'English (en)', locale: {
code: 'en', 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: { meta: {
pagination: { pagination: {
page: 1, page: 1,
@ -128,6 +207,7 @@ const mockReleaseDetailsPageData = {
noActionsBodyData: RELEASE_NO_ACTIONS_BODY_MOCK_DATA, noActionsBodyData: RELEASE_NO_ACTIONS_BODY_MOCK_DATA,
withActionsHeaderData: RELEASE_WITH_ACTIONS_HEADER_MOCK_DATA, withActionsHeaderData: RELEASE_WITH_ACTIONS_HEADER_MOCK_DATA,
withActionsBodyData: RELEASE_WITH_ACTIONS_BODY_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, withActionsAndPublishedHeaderData: PUBLISHED_RELEASE_WITH_ACTIONS_HEADER_MOCK_DATA,
} as const; } as const;

View File

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

View File

@ -26,9 +26,9 @@
"header.actions.created.description": " by {createdBy}", "header.actions.created.description": " by {createdBy}",
"modal.release-created-notification-success": "Release created", "modal.release-created-notification-success": "Release created",
"modal.release-updated-notification-success": "Release updated", "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.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.Details.header-subtitle": "{number, plural, =0 {No entries} one {# entry} other {# entries}}",
"pages.Releases.tab-group.label": "Releases list", "pages.Releases.tab-group.label": "Releases list",
"pages.Releases.tab.pending": "Pending", "pages.Releases.tab.pending": "Pending",
@ -40,10 +40,19 @@
"page.ReleaseDetails.table.header.label.locale": "locale", "page.ReleaseDetails.table.header.label.locale": "locale",
"page.ReleaseDetails.table.header.label.content-type": "content-type", "page.ReleaseDetails.table.header.label.content-type": "content-type",
"page.ReleaseDetails.table.header.label.action": "action", "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>.", "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.", "pages.ReleaseDetails.publish-notification-success": "Release was published successfully.",
"dialog.confirmation-message": "Are you sure you want to delete this release?", "dialog.confirmation-message": "Are you sure you want to delete this release?",
"page.Details.button.openContentManager": "Open the Content Manager", "page.Details.button.openContentManager": "Open the Content Manager",
"pages.Releases.notification.error.title": "Your request could not be processed.", "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", "@strapi/utils": "4.17.0",
"axios": "1.6.0", "axios": "1.6.0",
"formik": "2.4.0", "formik": "2.4.0",
"lodash": "4.17.21",
"react-intl": "6.4.1", "react-intl": "6.4.1",
"react-redux": "8.1.1", "react-redux": "8.1.1",
"yup": "0.32.9" "yup": "0.32.9"

View File

@ -33,6 +33,9 @@ export default {
type: 'string', type: 'string',
required: true, required: true,
}, },
locale: {
type: 'string',
},
release: { release: {
type: 'relation', type: 'relation',
relation: 'manyToOne', 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 type Koa from 'koa';
import { Entity } from '../../../shared/types';
import { import {
validateReleaseAction, validateReleaseAction,
@ -8,22 +7,12 @@ import {
import type { import type {
CreateReleaseAction, CreateReleaseAction,
GetReleaseActions, GetReleaseActions,
ReleaseAction,
UpdateReleaseAction, UpdateReleaseAction,
DeleteReleaseAction, DeleteReleaseAction,
} from '../../../shared/contracts/release-actions'; } from '../../../shared/contracts/release-actions';
import { getService } from '../utils'; import { getService } from '../utils';
import { RELEASE_ACTION_MODEL_UID } from '../constants'; 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 = { const releaseActionController = {
async create(ctx: Koa.Context) { async create(ctx: Koa.Context) {
const releaseId: CreateReleaseAction.Request['params']['releaseId'] = ctx.params.releaseId; const releaseId: CreateReleaseAction.Request['params']['releaseId'] = ctx.params.releaseId;
@ -38,6 +27,7 @@ const releaseActionController = {
data: releaseAction, data: releaseAction,
}; };
}, },
async findMany(ctx: Koa.Context) { async findMany(ctx: Koa.Context) {
const releaseId: GetReleaseActions.Request['params']['releaseId'] = ctx.params.releaseId; const releaseId: GetReleaseActions.Request['params']['releaseId'] = ctx.params.releaseId;
const permissionsManager = strapi.admin.services.permission.createPermissionsManager({ const permissionsManager = strapi.admin.services.permission.createPermissionsManager({
@ -47,36 +37,14 @@ const releaseActionController = {
const query = await permissionsManager.sanitizeQuery(ctx.query); const query = await permissionsManager.sanitizeQuery(ctx.query);
const releaseService = getService('release', { strapi }); const releaseService = getService('release', { strapi });
const { results, pagination } = await releaseService.findActions(releaseId, query); const { results, pagination } = await releaseService.findActions(releaseId, {
const allReleaseContentTypesDictionary = await releaseService.getContentTypesDataForActions( sort: query.groupBy === 'action' ? 'type' : query.groupBy,
releaseId ...query,
);
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 groupedData = await releaseService.groupActions(results, query.groupBy);
ctx.body = { ctx.body = {
data, data: groupedData,
meta: { meta: {
pagination, pagination,
}, },

View File

@ -205,4 +205,94 @@ describe('release service', () => {
expect(() => releaseService.delete(1)).rejects.toThrow('Release already published'); 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 { setCreatorFields, errors } from '@strapi/utils';
import type { LoadedStrapi, EntityService, UID } from '@strapi/types'; import type { LoadedStrapi, EntityService, UID } from '@strapi/types';
import _ from 'lodash/fp';
import { RELEASE_ACTION_MODEL_UID, RELEASE_MODEL_UID } from '../constants'; import { RELEASE_ACTION_MODEL_UID, RELEASE_MODEL_UID } from '../constants';
import type { import type {
GetReleases, GetReleases,
@ -17,10 +20,33 @@ import type {
ReleaseAction, ReleaseAction,
UpdateReleaseAction, UpdateReleaseAction,
DeleteReleaseAction, DeleteReleaseAction,
ReleaseActionGroupBy,
} from '../../../shared/contracts/release-actions'; } from '../../../shared/contracts/release-actions';
import type { UserInfo } from '../../../shared/types'; import type { Entity, UserInfo } from '../../../shared/types';
import { getService } from '../utils'; 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 }) => ({ const createReleaseService = ({ strapi }: { strapi: LoadedStrapi }) => ({
async create(releaseData: CreateRelease.Request['body'], { user }: { user: UserInfo }) { async create(releaseData: CreateRelease.Request['body'], { user }: { user: UserInfo }) {
const releaseWithCreatorFields = await setCreatorFields({ user })(releaseData); const releaseWithCreatorFields = await setCreatorFields({ user })(releaseData);
@ -166,6 +192,7 @@ const createReleaseService = ({ strapi }: { strapi: LoadedStrapi }) => ({
data: { data: {
type, type,
contentType: entry.contentType, contentType: entry.contentType,
locale: entry.locale,
entry: { entry: {
id: entry.id, id: entry.id,
__type: entry.contentType, __type: entry.contentType,
@ -181,9 +208,11 @@ const createReleaseService = ({ strapi }: { strapi: LoadedStrapi }) => ({
releaseId: GetReleaseActions.Request['params']['releaseId'], releaseId: GetReleaseActions.Request['params']['releaseId'],
query?: GetReleaseActions.Request['query'] 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}`); 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); return strapi.entityService.count(RELEASE_ACTION_MODEL_UID, query);
}, },
async getAllContentTypeUids(releaseId: Release['id']) { async groupActions(actions: ReleaseAction[], groupBy: ReleaseActionGroupBy) {
const contentTypesFromReleaseActions: { contentType: UID.ContentType }[] = await strapi.db const contentTypeUids = actions.reduce<ReleaseAction['contentType'][]>((acc, action) => {
.queryBuilder(RELEASE_ACTION_MODEL_UID) if (!acc.includes(action.contentType)) {
.select('content_type') acc.push(action.contentType);
.where({ }
$and: [
{
release: releaseId,
},
],
})
.groupBy('content_type')
.execute();
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']) { async getContentTypesDataForActions(contentTypesUids: ReleaseAction['contentType'][]) {
const contentTypesUids = await this.getAllContentTypeUids(releaseId);
const contentManagerContentTypeService = strapi const contentManagerContentTypeService = strapi
.plugin('content-manager') .plugin('content-manager')
.service('content-types'); .service('content-types');

View File

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

View File

@ -381,7 +381,8 @@ class Strapi implements StrapiI {
numberOfComponents: _.size(this.components), numberOfComponents: _.size(this.components),
numberOfDynamicZones: getNumberOfDynamicZones(), numberOfDynamicZones: getNumberOfDynamicZones(),
numberOfCustomControllers: Object.values<Common.Controller>(this.controllers).filter( 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, ).length,
environment: this.config.environment, environment: this.config.environment,
// TODO: to add back // TODO: to add back

View File

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

View File

@ -3298,15 +3298,6 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "@floating-ui/core@npm:^1.5.3":
version: 1.5.3 version: 1.5.3
resolution: "@floating-ui/core@npm:1.5.3" resolution: "@floating-ui/core@npm:1.5.3"
@ -3316,17 +3307,7 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@floating-ui/dom@npm:^1.0.1, @floating-ui/dom@npm:^1.5.1": "@floating-ui/dom@npm:^1.0.1, @floating-ui/dom@npm:^1.5.4":
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":
version: 1.5.4 version: 1.5.4
resolution: "@floating-ui/dom@npm:1.5.4" resolution: "@floating-ui/dom@npm:1.5.4"
dependencies: dependencies:
@ -3336,19 +3317,7 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@floating-ui/react-dom@npm:^2.0.0": "@floating-ui/react-dom@npm:^2.0.0, @floating-ui/react-dom@npm:^2.0.5":
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":
version: 2.0.5 version: 2.0.5
resolution: "@floating-ui/react-dom@npm:2.0.5" resolution: "@floating-ui/react-dom@npm:2.0.5"
dependencies: dependencies:
@ -3360,13 +3329,6 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "@floating-ui/utils@npm:^0.2.0":
version: 0.2.1 version: 0.2.1
resolution: "@floating-ui/utils@npm:0.2.1" resolution: "@floating-ui/utils@npm:0.2.1"
@ -7698,6 +7660,7 @@ __metadata:
axios: "npm:1.6.0" axios: "npm:1.6.0"
formik: "npm:2.4.0" formik: "npm:2.4.0"
koa: "npm:2.13.4" koa: "npm:2.13.4"
lodash: "npm:4.17.21"
msw: "npm:1.3.0" msw: "npm:1.3.0"
react: "npm:^18.2.0" react: "npm:^18.2.0"
react-dom: "npm:^18.2.0" react-dom: "npm:^18.2.0"