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 { 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 { isAxiosError } from 'axios';
import { useIntl } from 'react-intl';
import { useSelector, TypedUseSelectorHook } from 'react-redux';
import { NavLink } from 'react-router-dom';
import styled from 'styled-components';
import { DeleteReleaseAction, ReleaseAction } from '../../../shared/contracts/release-actions';
import { PERMISSIONS } from '../constants';
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';
type RootState = ReturnType<Store['getState']>;
const useTypedSelector: TypedUseSelectorHook<RootState> = useSelector;
const StyledMenuItem = styled(Menu.Item)<{ variant?: 'neutral' | 'danger' }>`
&:hover {
background: ${({ theme, variant = 'neutral' }) => theme.colors[`${variant}100`]};

View File

@ -1,5 +1,6 @@
import * as React from 'react';
import { unstable_useDocument } from '@strapi/admin/strapi-admin';
import {
Button,
ContentLayout,
@ -16,6 +17,7 @@ import {
SingleSelect,
SingleSelectOption,
Icon,
Tooltip,
} from '@strapi/design-system';
import { LinkButton } from '@strapi/design-system/v2';
import {
@ -33,7 +35,7 @@ import {
useRBAC,
AnErrorOccurred,
} 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 { useParams, useHistory, Link as ReactRouterLink, Redirect } from 'react-router-dom';
import styled from 'styled-components';
@ -51,12 +53,16 @@ import {
useUpdateReleaseActionMutation,
usePublishReleaseMutation,
useDeleteReleaseMutation,
releaseApi,
} from '../services/release';
import { useTypedDispatch } from '../store/hooks';
import type {
ReleaseAction,
ReleaseActionGroupBy,
ReleaseActionEntry,
} from '../../../shared/contracts/release-actions';
import type { Schema } from '@strapi/types';
/* -------------------------------------------------------------------------------------------------
* ReleaseDetailsLayout
@ -97,6 +103,10 @@ const TrashIcon = styled(Trash)`
}
`;
const TypographyMaxWidth = styled(Typography)`
max-width: 300px;
`;
interface PopoverButtonProps {
onClick?: (event: React.MouseEvent<HTMLElement>) => void;
disabled?: boolean;
@ -123,18 +133,49 @@ const PopoverButton = ({ onClick, disabled, children }: PopoverButtonProps) => {
};
interface EntryValidationTextProps {
status: ReleaseAction['entry']['status'];
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 { 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') {
return (
<Flex gap={2}>
<Icon color="success600" as={CheckCircle} />
{status === 'published' ? (
{entry.publishedAt ? (
<Typography textColor="success600" fontWeight="bold">
{formatMessage({
id: 'content-releases.pages.ReleaseDetails.entry-validation.already-published',
@ -156,7 +197,7 @@ const EntryValidationText = ({ status, action }: EntryValidationTextProps) => {
return (
<Flex gap={2}>
<Icon color="success600" as={CheckCircle} />
{status === 'draft' ? (
{!entry.publishedAt ? (
<Typography textColor="success600" fontWeight="bold">
{formatMessage({
id: 'content-releases.pages.ReleaseDetails.entry-validation.already-unpublished',
@ -201,6 +242,7 @@ export const ReleaseDetailsLayout = ({
const {
allowedActions: { canUpdate, canDelete },
} = useRBAC(PERMISSIONS);
const dispatch = useTypedDispatch();
const release = data?.data;
@ -245,6 +287,10 @@ export const ReleaseDetailsLayout = ({
handleTogglePopover();
};
const handleRefresh = () => {
dispatch(releaseApi.util.invalidateTags([{ type: 'ReleaseAction', id: 'LIST' }]));
};
if (isLoadingDetails) {
return (
<Main aria-busy={isLoadingDetails}>
@ -361,6 +407,12 @@ export const ReleaseDetailsLayout = ({
</ReleaseInfoWrapper>
</Popover>
)}
<Button size="S" variant="tertiary" onClick={handleRefresh}>
{formatMessage({
id: 'content-releases.header.actions.refresh',
defaultMessage: 'Refresh',
})}
</Button>
<CheckPermissions permissions={PERMISSIONS.publish}>
<Button
size="S"
@ -479,6 +531,8 @@ const ReleaseDetailsBody = () => {
const releaseActions = data?.data;
const releaseMeta = data?.meta;
const contentTypes = releaseMeta?.contentTypes || {};
const components = releaseMeta?.components || {};
if (isReleaseError || !release) {
const errorsArray = [];
@ -633,20 +687,18 @@ const ReleaseDetailsBody = () => {
</Table.Head>
<Table.LoadingBody />
<Table.Body>
{releaseActions[key].map(({ id, type, entry, contentType, locale }) => (
{releaseActions[key].map(({ id, contentType, locale, type, entry }) => (
<Tr key={id}>
<Td width="25%" maxWidth="200px">
<Typography ellipsis>{`${
entry.contentType.mainFieldValue || entry.id
contentType.mainFieldValue || entry.id
}`}</Typography>
</Td>
<Td width="10%">
<Typography>{`${
entry?.locale?.name ? entry.locale.name : '-'
}`}</Typography>
<Typography>{`${locale?.name ? locale.name : '-'}`}</Typography>
</Td>
<Td width="10%">
<Typography ellipsis>{entry.contentType.displayName || ''}</Typography>
<Typography>{contentType.displayName || ''}</Typography>
</Td>
<Td width="20%">
{release.releasedAt ? (
@ -676,15 +728,20 @@ const ReleaseDetailsBody = () => {
{!release.releasedAt && (
<>
<Td width="20%" minWidth="200px">
<EntryValidationText status={entry.status} action={type} />
<EntryValidationText
action={type}
schema={contentTypes?.[contentType.uid]}
components={components}
entry={entry}
/>
</Td>
<Td>
<Flex justifyContent="flex-end">
<ReleaseActionMenu.Root>
<ReleaseActionMenu.ReleaseActionEntryLinkItem
contentTypeUid={contentType}
contentTypeUid={contentType.uid}
entryId={entry.id}
locale={locale}
locale={locale?.code}
/>
<ReleaseActionMenu.DeleteReleaseActionItem
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', () => {
it('renders the details page with no actions', async () => {
server.use(
@ -97,19 +106,19 @@ describe('Releases details page', () => {
// should show the entries
expect(
screen.getByText(
mockReleaseDetailsPageData.withActionsBodyData.data['Category'][0].entry.contentType
mockReleaseDetailsPageData.withActionsBodyData.data['Category'][0].contentType
.mainFieldValue
)
).toBeInTheDocument();
expect(
screen.getByRole('gridcell', {
name: mockReleaseDetailsPageData.withActionsBodyData.data['Category'][0].entry.contentType
name: mockReleaseDetailsPageData.withActionsBodyData.data['Category'][0].contentType
.displayName,
})
).toBeInTheDocument();
expect(
screen.getByText(
mockReleaseDetailsPageData.withActionsBodyData.data['Category'][0].entry.locale.name
mockReleaseDetailsPageData.withActionsBodyData.data['Category'][0].locale.name
)
).toBeInTheDocument();

View File

@ -98,19 +98,19 @@ const RELEASE_WITH_ACTIONS_BODY_MOCK_DATA = {
{
id: 3,
type: 'publish',
contentType: 'api::category.category',
createdAt: '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: {
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,
pageCount: 1,
},
contentTypes: {},
components: {},
},
};
@ -134,39 +136,39 @@ const RELEASE_WITH_MULTIPLE_ACTIONS_BODY_MOCK_DATA = {
{
id: 3,
type: 'publish',
contentType: 'api::category.category',
createdAt: '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: {
id: 1,
contentType: {
displayName: 'Category',
mainFieldValue: 'cat1',
},
locale: {
name: 'English (en)',
code: 'en',
},
status: 'draft',
publishedAt: null,
},
},
{
id: 4,
type: 'unpublish',
contentType: 'api::category.category',
createdAt: '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: {
id: 2,
contentType: {
displayName: 'Category',
mainFieldValue: 'cat2',
},
locale: {
name: 'English (en)',
code: 'en',
},
status: 'published',
publishedAt: '2023-12-05T09:03:57.155Z',
},
},
],
@ -174,20 +176,20 @@ const RELEASE_WITH_MULTIPLE_ACTIONS_BODY_MOCK_DATA = {
{
id: 5,
type: 'publish',
contentType: 'api::address.address',
createdAt: '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: {
id: 1,
contentType: {
displayName: 'Address',
mainFieldValue: 'add1',
},
locale: {
name: 'English (en)',
code: 'en',
},
status: 'published',
publishedAt: '2023-12-05T09:03:57.155Z',
},
},
],
@ -199,6 +201,8 @@ const RELEASE_WITH_MULTIPLE_ACTIONS_BODY_MOCK_DATA = {
total: 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"
},
"devDependencies": {
"@strapi/admin": "4.17.1",
"@strapi/admin-test-utils": "4.17.1",
"@strapi/pack-up": "workspace:*",
"@strapi/strapi": "4.17.1",
@ -84,6 +85,7 @@
"typescript": "5.2.2"
},
"peerDependencies": {
"@strapi/admin": "^4.19.0",
"@strapi/strapi": "^4.15.1",
"react": "^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 { mapAsync } from '@strapi/utils';
import {
validateReleaseAction,
validateReleaseActionUpdateSchema,
@ -41,12 +42,48 @@ const releaseActionController = {
sort: query.groupBy === 'action' ? 'type' : query.groupBy,
...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 = {
data: groupedData,
meta: {
pagination,
contentTypes,
components,
},
};
},

View File

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

View File

@ -1,6 +1,6 @@
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';
@ -38,13 +38,13 @@ type LocaleDictionary = {
const getGroupName = (queryValue?: ReleaseActionGroupBy) => {
switch (queryValue) {
case 'contentType':
return 'entry.contentType.displayName';
return 'contentType.displayName';
case 'action':
return 'type';
case 'locale':
return _.getOr('No locale', 'entry.locale.name');
return _.getOr('No locale', 'locale.name');
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, {
...query,
populate: {
entry: true,
entry: {
populate: '*',
},
},
filters: {
release: releaseId,
@ -268,14 +270,11 @@ const createReleaseService = ({ strapi }: { strapi: LoadedStrapi }) => ({
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',
locale: action.locale ? allLocalesDictionary[action.locale] : null,
contentType: {
displayName,
mainFieldValue: action.entry[mainField],
uid: action.contentType,
},
};
});
@ -318,6 +317,47 @@ const createReleaseService = ({ strapi }: { strapi: LoadedStrapi }) => ({
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']) {
const release = (await strapi.entityService.findOne(RELEASE_MODEL_UID, releaseId, {
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 { Entity } from '../types';
import type { errors } from '@strapi/utils';
type ReleaseActionEntry = Entity & {
export type ReleaseActionEntry = Entity & {
// Entity attributes
[key: string]: Attribute.Any;
} & {
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 {
@ -33,6 +19,21 @@ export interface ReleaseAction extends Entity {
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
*/
@ -74,10 +75,12 @@ export declare namespace GetReleaseActions {
export interface Response {
data: {
[key: string]: Array<ReleaseAction & { entry: ReleaseActionEntryData }>;
[key: string]: Array<FormattedReleaseAction>;
};
meta: {
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"
dependencies:
"@reduxjs/toolkit": "npm:1.9.7"
"@strapi/admin": "npm:4.17.1"
"@strapi/admin-test-utils": "npm:4.17.1"
"@strapi/design-system": "npm:1.14.1"
"@strapi/helper-plugin": "npm:4.17.1"
@ -7932,6 +7933,7 @@ __metadata:
typescript: "npm:5.2.2"
yup: "npm:0.32.9"
peerDependencies:
"@strapi/admin": ^4.19.0
"@strapi/strapi": ^4.15.1
react: ^17.0.0 || ^18.0.0
react-dom: ^17.0.0 || ^18.0.0