feat(content-releases): Using useDocument for validations (#19222)

* feat(content-releases): introducing useDocument

* improve useDocument

* change useDocument to return a validate fn

* apply feedback

* apply josh feedback

* populate entries and sanitize them

* set strapi/admin version to 4.19.0
This commit is contained in:
Fernando Chávez 2024-01-17 15:13:55 +01:00 committed by GitHub
parent 29b5e272e5
commit bb1abb3cc9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 354 additions and 114 deletions

View File

@ -0,0 +1,59 @@
import { getYupInnerErrors } from '@strapi/helper-plugin';
import { Schema, Entity as StrapiEntity, Attribute } from '@strapi/types';
import { ValidationError } from 'yup';
import { createYupSchema } from '../content-manager/utils/validation';
export interface Entity {
id: StrapiEntity.ID;
createdAt: string;
updatedAt: string;
}
interface ValidateOptions {
contentType: Schema.ContentType;
components: {
[key: Schema.Component['uid']]: Schema.Component;
};
isCreatingEntry?: boolean;
}
/**
* @alpha - This hook is not stable and likely to change. Use at your own risk.
*/
export function useDocument() {
/**
* @TODO: Ideally, we should get the contentType and components schemas from the redux store
* But at the moment the store is populated only inside the content-manager by useContentManagerInitData
* So, we need to receive the content type schema and the components to use the function
*/
const validate = (
entry: Entity & { [key: string]: Attribute.Any },
{ contentType, components, isCreatingEntry = false }: ValidateOptions
) => {
const schema = createYupSchema(
// @ts-expect-error - @TODO: createYupSchema types need to be revisited
contentType,
{ components },
{ isCreatingEntry, isDraft: false, isJSONTestDisabled: true }
);
try {
schema.validateSync(entry, { abortEarly: false });
return {
errors: {},
};
} catch (error) {
if (error instanceof ValidationError) {
return {
errors: getYupInnerErrors(error),
};
}
throw error;
}
};
return { validate };
}

View File

@ -3,3 +3,5 @@ export * from './render';
export type { Store } from './core/store/configure'; export type { Store } from './core/store/configure';
export type { SanitizedAdminUser } from '../../shared/contracts/shared'; export type { SanitizedAdminUser } from '../../shared/contracts/shared';
export { useDocument as unstable_useDocument } from './hooks/useDocument';

View File

@ -6,20 +6,16 @@ import { CheckPermissions, useAPIErrorHandler, useNotification } from '@strapi/h
import { Cross, More, Pencil } from '@strapi/icons'; import { Cross, More, Pencil } from '@strapi/icons';
import { isAxiosError } from 'axios'; import { isAxiosError } from 'axios';
import { useIntl } from 'react-intl'; import { useIntl } from 'react-intl';
import { useSelector, TypedUseSelectorHook } from 'react-redux';
import { NavLink } from 'react-router-dom'; import { NavLink } from 'react-router-dom';
import styled from 'styled-components'; import styled from 'styled-components';
import { DeleteReleaseAction, ReleaseAction } from '../../../shared/contracts/release-actions'; import { DeleteReleaseAction, ReleaseAction } from '../../../shared/contracts/release-actions';
import { PERMISSIONS } from '../constants'; import { PERMISSIONS } from '../constants';
import { useDeleteReleaseActionMutation } from '../services/release'; import { useDeleteReleaseActionMutation } from '../services/release';
import { useTypedSelector } from '../store/hooks';
import type { Store } from '@strapi/admin/strapi-admin';
import type { Permission } from '@strapi/helper-plugin'; import type { Permission } from '@strapi/helper-plugin';
type RootState = ReturnType<Store['getState']>;
const useTypedSelector: TypedUseSelectorHook<RootState> = useSelector;
const StyledMenuItem = styled(Menu.Item)<{ variant?: 'neutral' | 'danger' }>` const StyledMenuItem = styled(Menu.Item)<{ variant?: 'neutral' | 'danger' }>`
&:hover { &:hover {
background: ${({ theme, variant = 'neutral' }) => theme.colors[`${variant}100`]}; background: ${({ theme, variant = 'neutral' }) => theme.colors[`${variant}100`]};

View File

@ -1,5 +1,6 @@
import * as React from 'react'; import * as React from 'react';
import { unstable_useDocument } from '@strapi/admin/strapi-admin';
import { import {
Button, Button,
ContentLayout, ContentLayout,
@ -16,6 +17,7 @@ import {
SingleSelect, SingleSelect,
SingleSelectOption, SingleSelectOption,
Icon, Icon,
Tooltip,
} from '@strapi/design-system'; } from '@strapi/design-system';
import { LinkButton } from '@strapi/design-system/v2'; import { LinkButton } from '@strapi/design-system/v2';
import { import {
@ -33,7 +35,7 @@ import {
useRBAC, useRBAC,
AnErrorOccurred, AnErrorOccurred,
} from '@strapi/helper-plugin'; } from '@strapi/helper-plugin';
import { ArrowLeft, CheckCircle, More, Pencil, Trash } from '@strapi/icons'; import { ArrowLeft, CheckCircle, More, Pencil, Trash, CrossCircle } 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';
@ -51,12 +53,16 @@ import {
useUpdateReleaseActionMutation, useUpdateReleaseActionMutation,
usePublishReleaseMutation, usePublishReleaseMutation,
useDeleteReleaseMutation, useDeleteReleaseMutation,
releaseApi,
} from '../services/release'; } from '../services/release';
import { useTypedDispatch } from '../store/hooks';
import type { import type {
ReleaseAction, ReleaseAction,
ReleaseActionGroupBy, ReleaseActionGroupBy,
ReleaseActionEntry,
} from '../../../shared/contracts/release-actions'; } from '../../../shared/contracts/release-actions';
import type { Schema } from '@strapi/types';
/* ------------------------------------------------------------------------------------------------- /* -------------------------------------------------------------------------------------------------
* ReleaseDetailsLayout * ReleaseDetailsLayout
@ -97,6 +103,10 @@ const TrashIcon = styled(Trash)`
} }
`; `;
const TypographyMaxWidth = styled(Typography)`
max-width: 300px;
`;
interface PopoverButtonProps { interface PopoverButtonProps {
onClick?: (event: React.MouseEvent<HTMLElement>) => void; onClick?: (event: React.MouseEvent<HTMLElement>) => void;
disabled?: boolean; disabled?: boolean;
@ -123,18 +133,49 @@ const PopoverButton = ({ onClick, disabled, children }: PopoverButtonProps) => {
}; };
interface EntryValidationTextProps { interface EntryValidationTextProps {
status: ReleaseAction['entry']['status'];
action: ReleaseAction['type']; action: ReleaseAction['type'];
schema: Schema.ContentType;
components: { [key: Schema.Component['uid']]: Schema.Component };
entry: ReleaseActionEntry;
} }
const EntryValidationText = ({ status, action }: EntryValidationTextProps) => { const EntryValidationText = ({ action, schema, components, entry }: EntryValidationTextProps) => {
const { formatMessage } = useIntl(); const { formatMessage } = useIntl();
const { validate } = unstable_useDocument();
const { errors } = validate(entry, {
contentType: schema,
components,
isCreatingEntry: false,
});
if (Object.keys(errors).length > 0) {
const validationErrorsMessages = Object.entries(errors)
.map(([key, value]) =>
formatMessage(
{ id: `${value.id}.withField`, defaultMessage: value.defaultMessage },
{ field: key }
)
)
.join(' ');
return (
<Flex gap={2}>
<Icon color="danger600" as={CrossCircle} />
<Tooltip description={validationErrorsMessages}>
<TypographyMaxWidth textColor="danger600" variant="omega" fontWeight="semiBold" ellipsis>
{validationErrorsMessages}
</TypographyMaxWidth>
</Tooltip>
</Flex>
);
}
if (action == 'publish') { if (action == 'publish') {
return ( return (
<Flex gap={2}> <Flex gap={2}>
<Icon color="success600" as={CheckCircle} /> <Icon color="success600" as={CheckCircle} />
{status === 'published' ? ( {entry.publishedAt ? (
<Typography textColor="success600" fontWeight="bold"> <Typography textColor="success600" fontWeight="bold">
{formatMessage({ {formatMessage({
id: 'content-releases.pages.ReleaseDetails.entry-validation.already-published', id: 'content-releases.pages.ReleaseDetails.entry-validation.already-published',
@ -156,7 +197,7 @@ const EntryValidationText = ({ status, action }: EntryValidationTextProps) => {
return ( return (
<Flex gap={2}> <Flex gap={2}>
<Icon color="success600" as={CheckCircle} /> <Icon color="success600" as={CheckCircle} />
{status === 'draft' ? ( {!entry.publishedAt ? (
<Typography textColor="success600" fontWeight="bold"> <Typography textColor="success600" fontWeight="bold">
{formatMessage({ {formatMessage({
id: 'content-releases.pages.ReleaseDetails.entry-validation.already-unpublished', id: 'content-releases.pages.ReleaseDetails.entry-validation.already-unpublished',
@ -201,6 +242,7 @@ export const ReleaseDetailsLayout = ({
const { const {
allowedActions: { canUpdate, canDelete }, allowedActions: { canUpdate, canDelete },
} = useRBAC(PERMISSIONS); } = useRBAC(PERMISSIONS);
const dispatch = useTypedDispatch();
const release = data?.data; const release = data?.data;
@ -245,6 +287,10 @@ export const ReleaseDetailsLayout = ({
handleTogglePopover(); handleTogglePopover();
}; };
const handleRefresh = () => {
dispatch(releaseApi.util.invalidateTags([{ type: 'ReleaseAction', id: 'LIST' }]));
};
if (isLoadingDetails) { if (isLoadingDetails) {
return ( return (
<Main aria-busy={isLoadingDetails}> <Main aria-busy={isLoadingDetails}>
@ -361,6 +407,12 @@ export const ReleaseDetailsLayout = ({
</ReleaseInfoWrapper> </ReleaseInfoWrapper>
</Popover> </Popover>
)} )}
<Button size="S" variant="tertiary" onClick={handleRefresh}>
{formatMessage({
id: 'content-releases.header.actions.refresh',
defaultMessage: 'Refresh',
})}
</Button>
<CheckPermissions permissions={PERMISSIONS.publish}> <CheckPermissions permissions={PERMISSIONS.publish}>
<Button <Button
size="S" size="S"
@ -479,6 +531,8 @@ const ReleaseDetailsBody = () => {
const releaseActions = data?.data; const releaseActions = data?.data;
const releaseMeta = data?.meta; const releaseMeta = data?.meta;
const contentTypes = releaseMeta?.contentTypes || {};
const components = releaseMeta?.components || {};
if (isReleaseError || !release) { if (isReleaseError || !release) {
const errorsArray = []; const errorsArray = [];
@ -633,20 +687,18 @@ const ReleaseDetailsBody = () => {
</Table.Head> </Table.Head>
<Table.LoadingBody /> <Table.LoadingBody />
<Table.Body> <Table.Body>
{releaseActions[key].map(({ id, type, entry, contentType, locale }) => ( {releaseActions[key].map(({ id, contentType, locale, type, entry }) => (
<Tr key={id}> <Tr key={id}>
<Td width="25%" maxWidth="200px"> <Td width="25%" maxWidth="200px">
<Typography ellipsis>{`${ <Typography ellipsis>{`${
entry.contentType.mainFieldValue || entry.id contentType.mainFieldValue || entry.id
}`}</Typography> }`}</Typography>
</Td> </Td>
<Td width="10%"> <Td width="10%">
<Typography>{`${ <Typography>{`${locale?.name ? locale.name : '-'}`}</Typography>
entry?.locale?.name ? entry.locale.name : '-'
}`}</Typography>
</Td> </Td>
<Td width="10%"> <Td width="10%">
<Typography ellipsis>{entry.contentType.displayName || ''}</Typography> <Typography>{contentType.displayName || ''}</Typography>
</Td> </Td>
<Td width="20%"> <Td width="20%">
{release.releasedAt ? ( {release.releasedAt ? (
@ -676,15 +728,20 @@ const ReleaseDetailsBody = () => {
{!release.releasedAt && ( {!release.releasedAt && (
<> <>
<Td width="20%" minWidth="200px"> <Td width="20%" minWidth="200px">
<EntryValidationText status={entry.status} action={type} /> <EntryValidationText
action={type}
schema={contentTypes?.[contentType.uid]}
components={components}
entry={entry}
/>
</Td> </Td>
<Td> <Td>
<Flex justifyContent="flex-end"> <Flex justifyContent="flex-end">
<ReleaseActionMenu.Root> <ReleaseActionMenu.Root>
<ReleaseActionMenu.ReleaseActionEntryLinkItem <ReleaseActionMenu.ReleaseActionEntryLinkItem
contentTypeUid={contentType} contentTypeUid={contentType.uid}
entryId={entry.id} entryId={entry.id}
locale={locale} locale={locale?.code}
/> />
<ReleaseActionMenu.DeleteReleaseActionItem <ReleaseActionMenu.DeleteReleaseActionItem
releaseId={release.id} releaseId={release.id}

View File

@ -17,6 +17,15 @@ jest.mock('@strapi/helper-plugin', () => ({
})), })),
})); }));
/**
* Mocking the useDocument hook to avoid validation errors for testing
*/
jest.mock('@strapi/admin/strapi-admin', () => ({
unstable_useDocument: jest
.fn()
.mockReturnValue({ validate: jest.fn().mockReturnValue({ errors: {} }) }),
}));
describe('Releases details page', () => { describe('Releases details page', () => {
it('renders the details page with no actions', async () => { it('renders the details page with no actions', async () => {
server.use( server.use(
@ -97,19 +106,19 @@ describe('Releases details page', () => {
// should show the entries // should show the entries
expect( expect(
screen.getByText( screen.getByText(
mockReleaseDetailsPageData.withActionsBodyData.data['Category'][0].entry.contentType mockReleaseDetailsPageData.withActionsBodyData.data['Category'][0].contentType
.mainFieldValue .mainFieldValue
) )
).toBeInTheDocument(); ).toBeInTheDocument();
expect( expect(
screen.getByRole('gridcell', { screen.getByRole('gridcell', {
name: mockReleaseDetailsPageData.withActionsBodyData.data['Category'][0].entry.contentType name: mockReleaseDetailsPageData.withActionsBodyData.data['Category'][0].contentType
.displayName, .displayName,
}) })
).toBeInTheDocument(); ).toBeInTheDocument();
expect( expect(
screen.getByText( screen.getByText(
mockReleaseDetailsPageData.withActionsBodyData.data['Category'][0].entry.locale.name mockReleaseDetailsPageData.withActionsBodyData.data['Category'][0].locale.name
) )
).toBeInTheDocument(); ).toBeInTheDocument();

View File

@ -98,19 +98,19 @@ const RELEASE_WITH_ACTIONS_BODY_MOCK_DATA = {
{ {
id: 3, id: 3,
type: 'publish', type: 'publish',
contentType: 'api::category.category',
createdAt: '2023-12-05T09:03:57.155Z', createdAt: '2023-12-05T09:03:57.155Z',
updatedAt: '2023-12-05T09:03:57.155Z', updatedAt: '2023-12-05T09:03:57.155Z',
contentType: {
displayName: 'Category',
mainFieldValue: 'cat1',
uid: 'api::category.category',
},
locale: {
name: 'English (en)',
code: 'en',
},
entry: { entry: {
id: 1, id: 1,
contentType: {
displayName: 'Category',
mainFieldValue: 'cat1',
},
locale: {
name: 'English (en)',
code: 'en',
},
}, },
}, },
], ],
@ -122,6 +122,8 @@ const RELEASE_WITH_ACTIONS_BODY_MOCK_DATA = {
total: 1, total: 1,
pageCount: 1, pageCount: 1,
}, },
contentTypes: {},
components: {},
}, },
}; };
@ -134,39 +136,39 @@ const RELEASE_WITH_MULTIPLE_ACTIONS_BODY_MOCK_DATA = {
{ {
id: 3, id: 3,
type: 'publish', type: 'publish',
contentType: 'api::category.category',
createdAt: '2023-12-05T09:03:57.155Z', createdAt: '2023-12-05T09:03:57.155Z',
updatedAt: '2023-12-05T09:03:57.155Z', updatedAt: '2023-12-05T09:03:57.155Z',
contentType: {
displayName: 'Category',
mainFieldValue: 'cat1',
uid: 'api::category.category',
},
locale: {
name: 'English (en)',
code: 'en',
},
entry: { entry: {
id: 1, id: 1,
contentType: { publishedAt: null,
displayName: 'Category',
mainFieldValue: 'cat1',
},
locale: {
name: 'English (en)',
code: 'en',
},
status: 'draft',
}, },
}, },
{ {
id: 4, id: 4,
type: 'unpublish', type: 'unpublish',
contentType: 'api::category.category',
createdAt: '2023-12-05T09:03:57.155Z', createdAt: '2023-12-05T09:03:57.155Z',
updatedAt: '2023-12-05T09:03:57.155Z', updatedAt: '2023-12-05T09:03:57.155Z',
contentType: {
displayName: 'Category',
mainFieldValue: 'cat2',
uid: 'api::category.category',
},
locale: {
name: 'English (en)',
code: 'en',
},
entry: { entry: {
id: 2, id: 2,
contentType: { publishedAt: '2023-12-05T09:03:57.155Z',
displayName: 'Category',
mainFieldValue: 'cat2',
},
locale: {
name: 'English (en)',
code: 'en',
},
status: 'published',
}, },
}, },
], ],
@ -174,20 +176,20 @@ const RELEASE_WITH_MULTIPLE_ACTIONS_BODY_MOCK_DATA = {
{ {
id: 5, id: 5,
type: 'publish', type: 'publish',
contentType: 'api::address.address',
createdAt: '2023-12-05T09:03:57.155Z', createdAt: '2023-12-05T09:03:57.155Z',
updatedAt: '2023-12-05T09:03:57.155Z', updatedAt: '2023-12-05T09:03:57.155Z',
contentType: {
displayName: 'Address',
mainFieldValue: 'add1',
uid: 'api::address.address',
},
locale: {
name: 'English (en)',
code: 'en',
},
entry: { entry: {
id: 1, id: 1,
contentType: { publishedAt: '2023-12-05T09:03:57.155Z',
displayName: 'Address',
mainFieldValue: 'add1',
},
locale: {
name: 'English (en)',
code: 'en',
},
status: 'published',
}, },
}, },
], ],
@ -199,6 +201,8 @@ const RELEASE_WITH_MULTIPLE_ACTIONS_BODY_MOCK_DATA = {
total: 1, total: 1,
pageCount: 1, pageCount: 1,
}, },
contentTypes: {},
components: {},
}, },
}; };

View File

@ -0,0 +1,11 @@
import { Dispatch } from '@reduxjs/toolkit';
import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux';
import type { Store } from '@strapi/admin/strapi-admin';
type RootState = ReturnType<Store['getState']>;
const useTypedDispatch: () => Dispatch = useDispatch;
const useTypedSelector: TypedUseSelectorHook<RootState> = useSelector;
export { useTypedSelector, useTypedDispatch };

View File

@ -68,6 +68,7 @@
"yup": "0.32.9" "yup": "0.32.9"
}, },
"devDependencies": { "devDependencies": {
"@strapi/admin": "4.17.1",
"@strapi/admin-test-utils": "4.17.1", "@strapi/admin-test-utils": "4.17.1",
"@strapi/pack-up": "workspace:*", "@strapi/pack-up": "workspace:*",
"@strapi/strapi": "4.17.1", "@strapi/strapi": "4.17.1",
@ -84,6 +85,7 @@
"typescript": "5.2.2" "typescript": "5.2.2"
}, },
"peerDependencies": { "peerDependencies": {
"@strapi/admin": "^4.19.0",
"@strapi/strapi": "^4.15.1", "@strapi/strapi": "^4.15.1",
"react": "^17.0.0 || ^18.0.0", "react": "^17.0.0 || ^18.0.0",
"react-dom": "^17.0.0 || ^18.0.0", "react-dom": "^17.0.0 || ^18.0.0",

View File

@ -165,6 +165,24 @@ describe('Release controller', () => {
}, },
}, },
}, },
plugins: {
// @ts-expect-error Ignore missing properties
'content-manager': {
services: {
'content-types': {
findAllContentTypes: jest
.fn()
.mockReturnValue([
{ uid: 'api::contentTypeA.contentTypeA' },
{ uid: 'api::contentTypeB.contentTypeB' },
]),
},
components: {
findAllComponents: jest.fn().mockReturnValue([{ uid: 'component.component' }]),
},
},
},
},
}; };
}); });

View File

@ -1,5 +1,6 @@
import type Koa from 'koa'; import type Koa from 'koa';
import { mapAsync } from '@strapi/utils';
import { import {
validateReleaseAction, validateReleaseAction,
validateReleaseActionUpdateSchema, validateReleaseActionUpdateSchema,
@ -41,12 +42,48 @@ const releaseActionController = {
sort: query.groupBy === 'action' ? 'type' : query.groupBy, sort: query.groupBy === 'action' ? 'type' : query.groupBy,
...query, ...query,
}); });
const groupedData = await releaseService.groupActions(results, query.groupBy);
/**
* Release actions can be related to entries of different content types.
* We need to sanitize the entry output according to that content type.
* So, we group the sanitized output function by content type.
*/
const contentTypeOutputSanitizers = results.reduce((acc, action) => {
if (acc[action.contentType]) {
return acc;
}
const contentTypePermissionsManager =
strapi.admin.services.permission.createPermissionsManager({
ability: ctx.state.userAbility,
model: action.contentType,
});
acc[action.contentType] = contentTypePermissionsManager.sanitizeOutput;
return acc;
}, {});
/**
* sanitizeOutput doesn't work if you use it directly on the Release Action model, it doesn't sanitize the entries
* So, we need to sanitize manually each entry inside a Release Action
*/
const sanitizedResults = await mapAsync(results, async (action) => ({
...action,
entry: await contentTypeOutputSanitizers[action.contentType](action.entry),
}));
const groupedData = await releaseService.groupActions(sanitizedResults, query.groupBy);
const contentTypes = releaseService.getContentTypeModelsFromActions(results);
const components = await releaseService.getAllComponents();
ctx.body = { ctx.body = {
data: groupedData, data: groupedData,
meta: { meta: {
pagination, pagination,
contentTypes,
components,
}, },
}; };
}, },

View File

@ -407,38 +407,38 @@ describe('release service', () => {
contentTypeA: [ contentTypeA: [
{ {
id: 1, id: 1,
locale: 'en', contentType: {
contentType: 'api::contentTypeA.contentTypeA', displayName: 'contentTypeA',
mainFieldValue: 'test 1',
uid: 'api::contentTypeA.contentTypeA',
},
locale: {
code: 'en',
name: 'English (en)',
},
entry: { entry: {
id: 1, id: 1,
contentType: { name: 'test 1',
displayName: 'contentTypeA', publishedAt: '2021-01-01',
mainFieldValue: 'test 1',
},
locale: {
code: 'en',
name: 'English (en)',
},
status: 'published',
}, },
}, },
], ],
contentTypeB: [ contentTypeB: [
{ {
id: 2, id: 2,
locale: 'fr', contentType: {
contentType: 'api::contentTypeB.contentTypeB', displayName: 'contentTypeB',
mainFieldValue: 'test 2',
uid: 'api::contentTypeB.contentTypeB',
},
locale: {
code: 'fr',
name: 'French (fr)',
},
entry: { entry: {
id: 2, id: 2,
contentType: { name: 'test 2',
displayName: 'contentTypeB', publishedAt: null,
mainFieldValue: 'test 2',
},
locale: {
code: 'fr',
name: 'French (fr)',
},
status: 'draft',
}, },
}, },
], ],

View File

@ -1,6 +1,6 @@
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, Schema } from '@strapi/types';
import _ from 'lodash/fp'; import _ from 'lodash/fp';
@ -38,13 +38,13 @@ type LocaleDictionary = {
const getGroupName = (queryValue?: ReleaseActionGroupBy) => { const getGroupName = (queryValue?: ReleaseActionGroupBy) => {
switch (queryValue) { switch (queryValue) {
case 'contentType': case 'contentType':
return 'entry.contentType.displayName'; return 'contentType.displayName';
case 'action': case 'action':
return 'type'; return 'type';
case 'locale': case 'locale':
return _.getOr('No locale', 'entry.locale.name'); return _.getOr('No locale', 'locale.name');
default: default:
return 'entry.contentType.displayName'; return 'contentType.displayName';
} }
}; };
@ -238,7 +238,9 @@ const createReleaseService = ({ strapi }: { strapi: LoadedStrapi }) => ({
return strapi.entityService.findPage(RELEASE_ACTION_MODEL_UID, { return strapi.entityService.findPage(RELEASE_ACTION_MODEL_UID, {
...query, ...query,
populate: { populate: {
entry: true, entry: {
populate: '*',
},
}, },
filters: { filters: {
release: releaseId, release: releaseId,
@ -268,14 +270,11 @@ const createReleaseService = ({ strapi }: { strapi: LoadedStrapi }) => ({
return { return {
...action, ...action,
entry: { locale: action.locale ? allLocalesDictionary[action.locale] : null,
id: action.entry.id, contentType: {
contentType: { displayName,
displayName, mainFieldValue: action.entry[mainField],
mainFieldValue: action.entry[mainField], uid: action.contentType,
},
locale: action.locale ? allLocalesDictionary[action.locale] : null,
status: action.entry.publishedAt ? 'published' : 'draft',
}, },
}; };
}); });
@ -318,6 +317,47 @@ const createReleaseService = ({ strapi }: { strapi: LoadedStrapi }) => ({
return contentTypesData; return contentTypesData;
}, },
getContentTypeModelsFromActions(actions: ReleaseAction[]) {
const contentTypeUids = actions.reduce<ReleaseAction['contentType'][]>((acc, action) => {
if (!acc.includes(action.contentType)) {
acc.push(action.contentType);
}
return acc;
}, []);
const contentTypeModelsMap = contentTypeUids.reduce(
(
acc: { [key: ReleaseAction['contentType']]: Schema.ContentType },
contentTypeUid: ReleaseAction['contentType']
) => {
acc[contentTypeUid] = strapi.getModel(contentTypeUid);
return acc;
},
{}
);
return contentTypeModelsMap;
},
async getAllComponents() {
const contentManagerComponentsService = strapi.plugin('content-manager').service('components');
const components = await contentManagerComponentsService.findAllComponents();
const componentsMap = components.reduce(
(acc: { [key: Schema.Component['uid']]: Schema.Component }, component: Schema.Component) => {
acc[component.uid] = component;
return acc;
},
{}
);
return componentsMap;
},
async delete(releaseId: DeleteRelease.Request['params']['id']) { async delete(releaseId: DeleteRelease.Request['params']['id']) {
const release = (await strapi.entityService.findOne(RELEASE_MODEL_UID, releaseId, { const release = (await strapi.entityService.findOne(RELEASE_MODEL_UID, releaseId, {
populate: { populate: {

View File

@ -1,28 +1,14 @@
import { Attribute, Common } from '@strapi/types'; import { Attribute, Common, Schema } from '@strapi/types';
import type { Release, Pagination } from './releases'; import type { Release, Pagination } from './releases';
import type { Entity } from '../types'; import type { Entity } from '../types';
import type { errors } from '@strapi/utils'; import type { errors } from '@strapi/utils';
type ReleaseActionEntry = Entity & { export type ReleaseActionEntry = Entity & {
// Entity attributes // Entity attributes
[key: string]: Attribute.Any; [key: string]: Attribute.Any;
} & { } & {
locale?: string; locale?: string;
status: 'published' | 'draft';
};
type ReleaseActionEntryData = {
id: ReleaseActionEntry['id'];
locale?: {
name: string;
code: string;
};
contentType: {
mainFieldValue?: string;
displayName: string;
};
status: 'published' | 'draft';
}; };
export interface ReleaseAction extends Entity { export interface ReleaseAction extends Entity {
@ -33,6 +19,21 @@ export interface ReleaseAction extends Entity {
release: Release; release: Release;
} }
export interface FormattedReleaseAction extends Entity {
type: 'publish' | 'unpublish';
entry: ReleaseActionEntry;
contentType: {
uid: Common.UID.ContentType;
mainFieldValue?: string;
displayName: string;
};
locale?: {
name: string;
code: string;
};
release: Release;
}
/** /**
* POST /content-releases/:releaseId/actions - Create a release action * POST /content-releases/:releaseId/actions - Create a release action
*/ */
@ -74,10 +75,12 @@ export declare namespace GetReleaseActions {
export interface Response { export interface Response {
data: { data: {
[key: string]: Array<ReleaseAction & { entry: ReleaseActionEntryData }>; [key: string]: Array<FormattedReleaseAction>;
}; };
meta: { meta: {
pagination: Pagination; pagination: Pagination;
contentTypes: Record<Schema.ContentType['uid'], Schema.ContentType>;
components: Record<Schema.Component['uid'], Schema.Component>;
}; };
} }
} }

View File

@ -7906,6 +7906,7 @@ __metadata:
resolution: "@strapi/content-releases@workspace:packages/core/content-releases" resolution: "@strapi/content-releases@workspace:packages/core/content-releases"
dependencies: dependencies:
"@reduxjs/toolkit": "npm:1.9.7" "@reduxjs/toolkit": "npm:1.9.7"
"@strapi/admin": "npm:4.17.1"
"@strapi/admin-test-utils": "npm:4.17.1" "@strapi/admin-test-utils": "npm:4.17.1"
"@strapi/design-system": "npm:1.14.1" "@strapi/design-system": "npm:1.14.1"
"@strapi/helper-plugin": "npm:4.17.1" "@strapi/helper-plugin": "npm:4.17.1"
@ -7932,6 +7933,7 @@ __metadata:
typescript: "npm:5.2.2" typescript: "npm:5.2.2"
yup: "npm:0.32.9" yup: "npm:0.32.9"
peerDependencies: peerDependencies:
"@strapi/admin": ^4.19.0
"@strapi/strapi": ^4.15.1 "@strapi/strapi": ^4.15.1
react: ^17.0.0 || ^18.0.0 react: ^17.0.0 || ^18.0.0
react-dom: ^17.0.0 || ^18.0.0 react-dom: ^17.0.0 || ^18.0.0