mirror of
https://github.com/strapi/strapi.git
synced 2025-09-08 08:08:18 +00:00
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:
parent
29b5e272e5
commit
bb1abb3cc9
59
packages/core/admin/admin/src/hooks/useDocument.ts
Normal file
59
packages/core/admin/admin/src/hooks/useDocument.ts
Normal 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 };
|
||||||
|
}
|
@ -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';
|
||||||
|
@ -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`]};
|
||||||
|
@ -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}
|
||||||
|
@ -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();
|
||||||
|
|
||||||
|
@ -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',
|
||||||
entry: {
|
|
||||||
id: 1,
|
|
||||||
contentType: {
|
contentType: {
|
||||||
displayName: 'Category',
|
displayName: 'Category',
|
||||||
mainFieldValue: 'cat1',
|
mainFieldValue: 'cat1',
|
||||||
|
uid: 'api::category.category',
|
||||||
},
|
},
|
||||||
locale: {
|
locale: {
|
||||||
name: 'English (en)',
|
name: 'English (en)',
|
||||||
code: 'en',
|
code: 'en',
|
||||||
},
|
},
|
||||||
|
entry: {
|
||||||
|
id: 1,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@ -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',
|
||||||
entry: {
|
|
||||||
id: 1,
|
|
||||||
contentType: {
|
contentType: {
|
||||||
displayName: 'Category',
|
displayName: 'Category',
|
||||||
mainFieldValue: 'cat1',
|
mainFieldValue: 'cat1',
|
||||||
|
uid: 'api::category.category',
|
||||||
},
|
},
|
||||||
locale: {
|
locale: {
|
||||||
name: 'English (en)',
|
name: 'English (en)',
|
||||||
code: 'en',
|
code: 'en',
|
||||||
},
|
},
|
||||||
status: 'draft',
|
entry: {
|
||||||
|
id: 1,
|
||||||
|
publishedAt: null,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
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',
|
||||||
entry: {
|
|
||||||
id: 2,
|
|
||||||
contentType: {
|
contentType: {
|
||||||
displayName: 'Category',
|
displayName: 'Category',
|
||||||
mainFieldValue: 'cat2',
|
mainFieldValue: 'cat2',
|
||||||
|
uid: 'api::category.category',
|
||||||
},
|
},
|
||||||
locale: {
|
locale: {
|
||||||
name: 'English (en)',
|
name: 'English (en)',
|
||||||
code: 'en',
|
code: 'en',
|
||||||
},
|
},
|
||||||
status: 'published',
|
entry: {
|
||||||
|
id: 2,
|
||||||
|
publishedAt: '2023-12-05T09:03:57.155Z',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@ -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',
|
||||||
entry: {
|
|
||||||
id: 1,
|
|
||||||
contentType: {
|
contentType: {
|
||||||
displayName: 'Address',
|
displayName: 'Address',
|
||||||
mainFieldValue: 'add1',
|
mainFieldValue: 'add1',
|
||||||
|
uid: 'api::address.address',
|
||||||
},
|
},
|
||||||
locale: {
|
locale: {
|
||||||
name: 'English (en)',
|
name: 'English (en)',
|
||||||
code: 'en',
|
code: 'en',
|
||||||
},
|
},
|
||||||
status: 'published',
|
entry: {
|
||||||
|
id: 1,
|
||||||
|
publishedAt: '2023-12-05T09:03:57.155Z',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@ -199,6 +201,8 @@ const RELEASE_WITH_MULTIPLE_ACTIONS_BODY_MOCK_DATA = {
|
|||||||
total: 1,
|
total: 1,
|
||||||
pageCount: 1,
|
pageCount: 1,
|
||||||
},
|
},
|
||||||
|
contentTypes: {},
|
||||||
|
components: {},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
11
packages/core/content-releases/admin/src/store/hooks.ts
Normal file
11
packages/core/content-releases/admin/src/store/hooks.ts
Normal 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 };
|
@ -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",
|
||||||
|
@ -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' }]),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -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,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
@ -406,39 +406,39 @@ describe('release service', () => {
|
|||||||
expect(groupedData).toEqual({
|
expect(groupedData).toEqual({
|
||||||
contentTypeA: [
|
contentTypeA: [
|
||||||
{
|
{
|
||||||
id: 1,
|
|
||||||
locale: 'en',
|
|
||||||
contentType: 'api::contentTypeA.contentTypeA',
|
|
||||||
entry: {
|
|
||||||
id: 1,
|
id: 1,
|
||||||
contentType: {
|
contentType: {
|
||||||
displayName: 'contentTypeA',
|
displayName: 'contentTypeA',
|
||||||
mainFieldValue: 'test 1',
|
mainFieldValue: 'test 1',
|
||||||
|
uid: 'api::contentTypeA.contentTypeA',
|
||||||
},
|
},
|
||||||
locale: {
|
locale: {
|
||||||
code: 'en',
|
code: 'en',
|
||||||
name: 'English (en)',
|
name: 'English (en)',
|
||||||
},
|
},
|
||||||
status: 'published',
|
entry: {
|
||||||
|
id: 1,
|
||||||
|
name: 'test 1',
|
||||||
|
publishedAt: '2021-01-01',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
contentTypeB: [
|
contentTypeB: [
|
||||||
{
|
{
|
||||||
id: 2,
|
|
||||||
locale: 'fr',
|
|
||||||
contentType: 'api::contentTypeB.contentTypeB',
|
|
||||||
entry: {
|
|
||||||
id: 2,
|
id: 2,
|
||||||
contentType: {
|
contentType: {
|
||||||
displayName: 'contentTypeB',
|
displayName: 'contentTypeB',
|
||||||
mainFieldValue: 'test 2',
|
mainFieldValue: 'test 2',
|
||||||
|
uid: 'api::contentTypeB.contentTypeB',
|
||||||
},
|
},
|
||||||
locale: {
|
locale: {
|
||||||
code: 'fr',
|
code: 'fr',
|
||||||
name: 'French (fr)',
|
name: 'French (fr)',
|
||||||
},
|
},
|
||||||
status: 'draft',
|
entry: {
|
||||||
|
id: 2,
|
||||||
|
name: 'test 2',
|
||||||
|
publishedAt: null,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
@ -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: {
|
||||||
|
@ -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>;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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
|
||||||
|
Loading…
x
Reference in New Issue
Block a user