mirror of
https://github.com/strapi/strapi.git
synced 2025-09-25 08:19:07 +00:00
Merge branch 'main' into releases/4.17.0
This commit is contained in:
commit
91ffe59a86
@ -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 },
|
||||
|
@ -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}>
|
||||
|
@ -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>
|
||||
}
|
||||
/>
|
||||
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -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 };
|
||||
|
@ -349,10 +349,4 @@ const ReleasesPage = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const ProtectedReleasesPage = () => (
|
||||
<CheckPermissions permissions={PERMISSIONS.main}>
|
||||
<ReleasesPage />
|
||||
</CheckPermissions>
|
||||
);
|
||||
|
||||
export { ReleasesPage, ProtectedReleasesPage };
|
||||
export { ReleasesPage };
|
||||
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
@ -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;
|
||||
|
||||
|
@ -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,
|
||||
|
@ -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"
|
||||
}
|
||||
|
@ -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"
|
||||
|
@ -33,6 +33,9 @@ export default {
|
||||
type: 'string',
|
||||
required: true,
|
||||
},
|
||||
locale: {
|
||||
type: 'string',
|
||||
},
|
||||
release: {
|
||||
type: 'relation',
|
||||
relation: 'manyToOne',
|
||||
|
@ -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)',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -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,
|
||||
},
|
||||
|
@ -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',
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -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');
|
||||
|
@ -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;
|
||||
};
|
||||
|
@ -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
|
||||
|
@ -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,
|
||||
|
43
yarn.lock
43
yarn.lock
@ -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"
|
||||
|
Loading…
x
Reference in New Issue
Block a user