enhancement: add relation data to history versions response (#20035)

* feat: add relation data to history versions response

* fix: restore unknown attributes

* fix: admin test ts errors

* chore: mark feedback
This commit is contained in:
Rémi de Juvigny 2024-04-04 17:31:51 -04:00 committed by GitHub
parent ab56a0f690
commit 163e91acfe
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 506 additions and 154 deletions

View File

@ -1,5 +1,15 @@
import { Form } from '@strapi/admin/strapi-admin';
import { Box, ContentLayout, Flex, Grid, GridItem, Typography } from '@strapi/design-system';
import * as React from 'react';
import { Form, useField, useStrapiApp } from '@strapi/admin/strapi-admin';
import {
Box,
ContentLayout,
FieldLabel,
Flex,
Grid,
GridItem,
Typography,
} from '@strapi/design-system';
import {
InputRenderer,
@ -7,19 +17,84 @@ import {
} from '../../pages/EditView/components/InputRenderer';
import { useHistoryContext } from '../pages/History';
import type { RelationsFieldProps } from '../../pages/EditView/components/FormInputs/Relations';
/* -------------------------------------------------------------------------------------------------
* CustomRelationInput
* -----------------------------------------------------------------------------------------------*/
const CustomRelationInput = (props: RelationsFieldProps) => {
const field = useField(props.name);
return (
<Box>
<FieldLabel>{props.label}</FieldLabel>
{field.value.results.length === 0 ? (
<Typography>No content</Typography>
) : (
<Flex direction="column" gap={2} alignItems="stretch">
{(field.value.results as Record<string, unknown>[]).map((relationData, index) => {
return (
<Flex
key={index}
paddingTop={2}
paddingBottom={2}
paddingLeft={4}
paddingRight={4}
hasRadius
borderColor="neutral200"
background="neutral150"
justifyContent="space-between"
>
<pre>
<Typography as="code">{JSON.stringify(relationData, null, 2)}</Typography>
</pre>
</Flex>
);
})}
<Typography>{field.value.meta.missingCount} missing relations</Typography>
</Flex>
)}
</Box>
);
};
/* -------------------------------------------------------------------------------------------------
* CustomMediaInput
* -----------------------------------------------------------------------------------------------*/
const CustomMediaInput = (props: InputRendererProps) => {
const field = useField(props.name);
const fields = useStrapiApp('CustomMediaInput', (state) => state.fields);
const MediaLibrary = fields.media as React.ComponentType<
InputRendererProps & { multiple: boolean }
>;
return (
<Box>
<Flex direction="column" gap={2} alignItems="stretch">
<Form method="PUT" disabled={true} initialValues={{ [props.name]: field.value.results }}>
<MediaLibrary {...props} disabled={true} multiple={field.value.results.length > 1} />
</Form>
<Typography>{field.value.meta.missingCount} missing relations</Typography>
</Flex>
</Box>
);
};
/* -------------------------------------------------------------------------------------------------
* CustomInputRenderer
* -----------------------------------------------------------------------------------------------*/
// The renderers for these types will be added in future PRs, they need special handling
const UNSUPPORTED_TYPES = ['media', 'relation'];
const CustomInputRenderer = (props: InputRendererProps) => {
if (UNSUPPORTED_TYPES.includes(props.type)) {
return <Typography>TODO: support {props.type}</Typography>;
switch (props.type) {
case 'media':
return <CustomMediaInput {...props} />;
case 'relation':
return <CustomRelationInput {...props} />;
default:
return <InputRenderer {...props} />;
}
return <InputRenderer {...props} />;
};
/* -------------------------------------------------------------------------------------------------

View File

@ -52,6 +52,7 @@ describe('VersionHeader', () => {
createdAt: '2022-01-01T00:00:00Z',
status: 'draft' as const,
schema: {},
componentsSchemas: {},
locale: null,
data: {
title: 'Test Title',
@ -132,6 +133,7 @@ describe('VersionHeader', () => {
createdAt: '2022-01-01T00:00:00Z',
status: 'draft' as const,
schema: {},
componentsSchemas: {},
locale: null,
data: {
title: 'Test Title',

View File

@ -1,4 +1,4 @@
import type { Struct, UID } from '@strapi/types';
import type { UID } from '@strapi/types';
import { scheduleJob } from 'node-schedule';
import { HISTORY_VERSION_UID } from '../../constants';
import { createHistoryService } from '../history';
@ -23,7 +23,7 @@ const mockGetRequestContext = jest.fn(() => {
},
};
});
const mockFindOne = jest.fn();
const mockStrapi = {
plugins: {
'content-manager': {
@ -35,7 +35,6 @@ const mockStrapi = {
i18n: {
service: jest.fn(() => ({
getDefaultLocale: jest.fn().mockReturnValue('en'),
find: jest.fn(),
})),
},
},
@ -65,7 +64,7 @@ const mockStrapi = {
},
},
documents: jest.fn(() => ({
findOne: jest.fn(),
findOne: mockFindOne,
})),
config: {
get: () => undefined,
@ -73,13 +72,42 @@ const mockStrapi = {
requestContext: {
get: mockGetRequestContext,
},
contentType(uid: UID.ContentType) {
getModel(uid: UID.Schema) {
if (uid === 'api::article.article') {
return {
attributes: {
title: {
type: 'string',
},
relation: {
type: 'relation',
target: 'api::category.category',
},
component: {
type: 'component',
component: 'some.component',
},
media: {
type: 'media',
},
},
};
}
if (uid === 'some.component') {
return {
attributes: {
title: {
type: 'string',
},
relation: {
type: 'relation',
target: 'api::restaurant.restaurant',
},
medias: {
type: 'media',
multiple: true,
},
},
};
}
@ -96,140 +124,192 @@ describe('history-version service', () => {
jest.useRealTimers();
});
describe('boostrap', () => {
it('inits service only once', () => {
historyService.bootstrap();
historyService.bootstrap();
// @ts-expect-error - ignore
expect(mockStrapi.documents.use).toHaveBeenCalledTimes(1);
});
it('inits service only once', () => {
historyService.bootstrap();
historyService.bootstrap();
// @ts-expect-error - ignore
expect(mockStrapi.documents.use).toHaveBeenCalledTimes(1);
});
it('saves relevant document actions in history', async () => {
const context = {
action: 'create',
contentType: {
uid: 'api::article.article',
it('saves relevant document actions in history', async () => {
const context = {
action: 'create',
contentType: {
uid: 'api::article.article',
},
args: [
{
locale: 'fr',
},
args: [
{
locale: 'fr',
],
};
const next = jest.fn((context) => ({ ...context, documentId: 'document-id' }));
await historyService.bootstrap();
// @ts-expect-error - ignore
const historyMiddlewareFunction = mockStrapi.documents.use.mock.calls[0][0];
// Check that we don't break the middleware chain
await historyMiddlewareFunction(context, next);
expect(next).toHaveBeenCalled();
// Ensure we're only storing the data we need in the database
expect(mockFindOne).toHaveBeenLastCalledWith('document-id', {
locale: 'fr',
populate: {
component: {
populate: {
relation: {
fields: ['documentId', 'locale', 'publishedAt'],
},
medias: {
fields: ['id'],
},
},
],
};
const next = jest.fn((context) => ({ ...context, documentId: 'document-id' }));
await historyService.bootstrap();
// @ts-expect-error - ignore
const historyMiddlewareFunction = mockStrapi.documents.use.mock.calls[0][0];
// Check that we don't break the middleware chain
await historyMiddlewareFunction(context, next);
expect(next).toHaveBeenCalled();
// Create and update actions should be saved in history
expect(createMock).toHaveBeenCalled();
context.action = 'update';
await historyMiddlewareFunction(context, next);
expect(createMock).toHaveBeenCalledTimes(2);
// Publish and unpublish actions should be saved in history
createMock.mockClear();
await historyMiddlewareFunction(context, next);
context.action = 'unpublish';
await historyMiddlewareFunction(context, next);
expect(createMock).toHaveBeenCalledTimes(2);
// Other actions should be ignored
createMock.mockClear();
context.action = 'findOne';
await historyMiddlewareFunction(context, next);
context.action = 'delete';
await historyMiddlewareFunction(context, next);
expect(createMock).toHaveBeenCalledTimes(0);
// Non-api content types should be ignored
createMock.mockClear();
context.contentType.uid = 'plugin::upload.file';
context.action = 'create';
await historyMiddlewareFunction(context, next);
expect(createMock).toHaveBeenCalledTimes(0);
// Don't break middleware chain even if we don't save the action in history
next.mockClear();
await historyMiddlewareFunction(context, next);
expect(next).toHaveBeenCalled();
},
relation: {
fields: ['documentId', 'locale', 'publishedAt'],
},
media: {
fields: ['id'],
},
},
});
it('should create a cron job that runs once a day', async () => {
// @ts-expect-error - this is a mock
const mockScheduleJob = scheduleJob.mockImplementationOnce(
jest.fn((rule, callback) => callback())
);
// Create and update actions should be saved in history
const createPayload = createMock.mock.calls.at(-1)[0].data;
expect(createPayload.schema).toEqual({
title: {
type: 'string',
},
relation: {
type: 'relation',
target: 'api::category.category',
},
component: {
type: 'component',
component: 'some.component',
},
media: {
type: 'media',
},
});
expect(createPayload.componentsSchemas).toEqual({
'some.component': {
title: {
type: 'string',
},
relation: {
type: 'relation',
target: 'api::restaurant.restaurant',
},
medias: {
type: 'media',
multiple: true,
},
},
});
context.action = 'update';
await historyMiddlewareFunction(context, next);
expect(createMock).toHaveBeenCalledTimes(2);
await historyService.bootstrap();
// Publish and unpublish actions should be saved in history
createMock.mockClear();
await historyMiddlewareFunction(context, next);
context.action = 'unpublish';
await historyMiddlewareFunction(context, next);
expect(createMock).toHaveBeenCalledTimes(2);
expect(mockScheduleJob).toHaveBeenCalledTimes(1);
expect(mockScheduleJob).toHaveBeenCalledWith('0 0 * * *', expect.any(Function));
// Other actions should be ignored
createMock.mockClear();
context.action = 'findOne';
await historyMiddlewareFunction(context, next);
context.action = 'delete';
await historyMiddlewareFunction(context, next);
expect(createMock).toHaveBeenCalledTimes(0);
// Non-api content types should be ignored
createMock.mockClear();
context.contentType.uid = 'plugin::upload.file';
context.action = 'create';
await historyMiddlewareFunction(context, next);
expect(createMock).toHaveBeenCalledTimes(0);
// Don't break middleware chain even if we don't save the action in history
next.mockClear();
await historyMiddlewareFunction(context, next);
expect(next).toHaveBeenCalled();
});
it('creates a history version with the author', async () => {
jest.useFakeTimers().setSystemTime(fakeDate);
const historyVersionData = {
contentType: 'api::article.article' as UID.ContentType,
data: {
title: 'My article',
},
locale: 'en',
relatedDocumentId: 'randomid',
schema: {
title: {
type: 'string' as const,
},
},
componentsSchemas: {},
status: 'draft' as const,
};
await historyService.createVersion(historyVersionData);
expect(createMock).toHaveBeenCalledWith({
data: {
...historyVersionData,
createdBy: userId,
createdAt: fakeDate,
},
});
});
describe('createVersion', () => {
it('creates a history version with the author', async () => {
jest.useFakeTimers().setSystemTime(fakeDate);
it('creates a history version without any author', async () => {
jest.useFakeTimers().setSystemTime(fakeDate);
const historyVersionData = {
contentType: 'api::article.article' as UID.ContentType,
data: {
title: 'My article',
const historyVersionData = {
contentType: 'api::article.article' as UID.ContentType,
data: {
title: 'My article',
},
locale: 'en',
relatedDocumentId: 'randomid',
componentsSchemas: {},
schema: {
title: {
type: 'string' as const,
},
locale: 'en',
relatedDocumentId: 'randomid',
schema: {
title: {
type: 'string',
},
} as Struct.SchemaAttributes,
status: 'draft' as const,
};
},
status: null,
};
await historyService.createVersion(historyVersionData);
expect(createMock).toHaveBeenCalledWith({
data: {
...historyVersionData,
createdBy: userId,
createdAt: fakeDate,
},
});
mockGetRequestContext.mockReturnValueOnce(null as any);
await historyService.createVersion(historyVersionData);
expect(createMock).toHaveBeenCalledWith({
data: {
...historyVersionData,
createdBy: undefined,
createdAt: fakeDate,
},
});
});
it('creates a history version without any author', async () => {
jest.useFakeTimers().setSystemTime(fakeDate);
it('should create a cron job that runs once a day', async () => {
// @ts-expect-error - this is a mock
const mockScheduleJob = scheduleJob.mockImplementationOnce(
jest.fn((rule, callback) => callback())
);
const historyVersionData = {
contentType: 'api::article.article' as UID.ContentType,
data: {
title: 'My article',
},
locale: 'en',
relatedDocumentId: 'randomid',
schema: {
title: {
type: 'string',
},
} as Struct.SchemaAttributes,
status: null,
};
await historyService.bootstrap();
mockGetRequestContext.mockReturnValueOnce(null as any);
await historyService.createVersion(historyVersionData);
expect(createMock).toHaveBeenCalledWith({
data: {
...historyVersionData,
createdBy: undefined,
createdAt: fakeDate,
},
});
});
expect(mockScheduleJob).toHaveBeenCalledTimes(1);
expect(mockScheduleJob).toHaveBeenCalledWith('0 0 * * *', expect.any(Function));
});
});

View File

@ -1,12 +1,21 @@
import type { Core, Modules } from '@strapi/types';
import type { Core, Modules, UID, Data, Schema } from '@strapi/types';
import { contentTypes } from '@strapi/utils';
import { omit, pick } from 'lodash/fp';
import { scheduleJob } from 'node-schedule';
import { FIELDS_TO_IGNORE, HISTORY_VERSION_UID } from '../constants';
import type { HistoryVersions } from '../../../../shared/contracts';
import {
CreateHistoryVersion,
HistoryVersionDataResponse,
} from '../../../../shared/contracts/history-versions';
import { getSchemaAttributesDiff } from './utils';
// Needed because the query engine doesn't return any types yet
type HistoryVersionQueryResult = Omit<HistoryVersionDataResponse, 'locale'> &
Pick<CreateHistoryVersion, 'locale'>;
const DEFAULT_RETENTION_DAYS = 90;
const createHistoryService = ({ strapi }: { strapi: Core.Strapi }) => {
@ -63,6 +72,56 @@ const createHistoryService = ({ strapi }: { strapi: Core.Strapi }) => {
return documentMetadataService.getStatus(document, meta.availableStatus);
};
/**
* Creates a populate object that looks for all the relations that need
* to be saved in history, and populates only the fields needed to later retrieve the content.
*/
const getDeepPopulate = (uid: UID.Schema) => {
const model = strapi.getModel(uid);
const attributes = Object.entries(model.attributes);
return attributes.reduce((acc: any, [attributeName, attribute]) => {
switch (attribute.type) {
case 'relation': {
const isVisible = contentTypes.isVisibleAttribute(model, attributeName);
if (isVisible) {
acc[attributeName] = { fields: ['documentId', 'locale', 'publishedAt'] };
}
break;
}
case 'media': {
acc[attributeName] = { fields: ['id'] };
break;
}
case 'component': {
const populate = getDeepPopulate(attribute.component);
acc[attributeName] = { populate };
break;
}
case 'dynamiczone': {
// Use fragments to populate the dynamic zone components
const populatedComponents = (attribute.components || []).reduce(
(acc: any, componentUID: UID.Component) => {
acc[componentUID] = { populate: getDeepPopulate(componentUID) };
return acc;
},
{}
);
acc[attributeName] = { on: populatedComponents };
break;
}
default:
break;
}
return acc;
}, {});
};
return {
async bootstrap() {
// Prevent initializing the service twice
@ -106,16 +165,42 @@ const createHistoryService = ({ strapi }: { strapi: Core.Strapi }) => {
.documents(contentTypeUid)
.findOne(documentContext.documentId, {
locale,
populate: getDeepPopulate(contentTypeUid),
});
const status = await getVersionStatus(contentTypeUid, document);
/**
* Store schema of both the fields and the fields of the attributes, as it will let us know
* if changes were made in the CTB since a history version was created,
* and therefore which fields can be restored and which cannot.
*/
const attributesSchema = strapi.getModel(contentTypeUid).attributes;
const componentsSchemas: CreateHistoryVersion['componentsSchemas'] = Object.keys(
attributesSchema
).reduce((currentComponentSchemas, key) => {
const fieldSchema = attributesSchema[key];
if (fieldSchema.type === 'component') {
const componentSchema = strapi.getModel(fieldSchema.component).attributes;
return {
...currentComponentSchemas,
[fieldSchema.component]: componentSchema,
};
}
// Ignore anything that's not a component
return currentComponentSchemas;
}, {});
// Prevent creating a history version for an action that wasn't actually executed
await strapi.db.transaction(async ({ onCommit }) => {
onCommit(() => {
this.createVersion({
contentType: contentTypeUid,
data: omit(FIELDS_TO_IGNORE, document),
schema: omit(FIELDS_TO_IGNORE, strapi.contentType(contentTypeUid).attributes),
schema: omit(FIELDS_TO_IGNORE, attributesSchema),
componentsSchemas,
relatedDocumentId: documentContext.documentId,
locale,
status,
@ -160,7 +245,10 @@ const createHistoryService = ({ strapi }: { strapi: Core.Strapi }) => {
});
},
async findVersionsPage(params: HistoryVersions.GetHistoryVersions.Request['query']) {
async findVersionsPage(params: HistoryVersions.GetHistoryVersions.Request['query']): Promise<{
results: HistoryVersions.HistoryVersionDataResponse[];
pagination: HistoryVersions.Pagination;
}> {
const [{ results, pagination }, localeDictionary] = await Promise.all([
query.findPage({
...params,
@ -177,25 +265,131 @@ const createHistoryService = ({ strapi }: { strapi: Core.Strapi }) => {
getLocaleDictionary(),
]);
const versionsWithMeta = results.map((version) => {
return {
...version,
meta: {
unknownAttributes: getSchemaAttributesDiff(
version.schema,
strapi.getModel(params.contentType).attributes
),
},
};
});
type EntryToPopulate =
| {
documentId: string;
locale: string | null;
}
| { id: Data.ID }
| null;
const sanitizedResults = versionsWithMeta.map((result) => ({
...result,
locale: result.locale ? localeDictionary[result.locale] : null,
createdBy: result.createdBy
? pick(['id', 'firstname', 'lastname', 'username', 'email'], result.createdBy)
: null,
}));
/**
* Get an object with two keys:
* - results: an array with the current values of the relations
* - meta: an object with the count of missing relations
*/
const buildRelationReponse = async (
values: EntryToPopulate[],
attributeSchema: Schema.Attribute.AnyAttribute
): Promise<{ results: any[]; meta: { missingCount: number } }> => {
return (
values
// Until we implement proper pagination, limit relations to an arbitrary amount
.slice(0, 25)
.reduce(
async (currentRelationDataPromise, entry) => {
const currentRelationData = await currentRelationDataPromise;
// Entry can be null if it's a toOne relation
if (!entry) {
return currentRelationData;
}
const isNormalRelation =
attributeSchema.type === 'relation' &&
attributeSchema.relation !== 'morphToOne' &&
attributeSchema.relation !== 'morphToMany';
/**
* Adapt the query depending on if the attribute is a media
* or a normal relation. The extra checks are only for type narrowing
*/
let relatedEntry;
if (isNormalRelation) {
if ('documentId' in entry) {
relatedEntry = await strapi
.documents(attributeSchema.target)
.findOne(entry.documentId, { locale: entry.locale || undefined });
}
// For media assets, only the id is available, double check that we have it
} else if ('id' in entry) {
relatedEntry = await strapi.db
.query('plugin::upload.file')
.findOne({ where: { id: entry.id } });
}
if (relatedEntry) {
currentRelationData.results.push({
...relatedEntry,
...(isNormalRelation
? {
status: await getVersionStatus(attributeSchema.target, relatedEntry),
}
: {}),
});
} else {
// The related content has been deleted
currentRelationData.meta.missingCount += 1;
}
return currentRelationData;
},
Promise.resolve({
results: [] as any[],
meta: { missingCount: 0 },
})
)
);
};
const populateEntryRelations = async (
entry: HistoryVersionQueryResult
): Promise<CreateHistoryVersion['data']> => {
const entryWithRelations = await Object.entries(entry.schema).reduce(
async (currentDataWithRelations, [attributeKey, attributeSchema]) => {
// TODO: handle relations that are inside components
if (['relation', 'media'].includes(attributeSchema.type)) {
const attributeValue = entry.data[attributeKey];
const relationResponse = await buildRelationReponse(
(Array.isArray(attributeValue)
? attributeValue
: [attributeValue]) as EntryToPopulate[],
attributeSchema
);
return {
...(await currentDataWithRelations),
[attributeKey]: relationResponse,
};
}
// Not a media or relation, nothing to change
return currentDataWithRelations;
},
Promise.resolve(entry.data)
);
return entryWithRelations;
};
const sanitizedResults = await Promise.all(
(results as HistoryVersionQueryResult[]).map(async (result) => {
return {
...result,
data: await populateEntryRelations(result),
meta: {
unknownAttributes: getSchemaAttributesDiff(
result.schema,
strapi.getModel(params.contentType).attributes
),
},
locale: result.locale ? localeDictionary[result.locale] : null,
createdBy: result.createdBy
? pick(['id', 'firstname', 'lastname', 'username', 'email'], result.createdBy)
: undefined,
};
})
);
return {
results: sanitizedResults,

View File

@ -1,4 +1,4 @@
import type { Data, Struct, UID } from '@strapi/types';
import type { Data, Schema, Struct, UID } from '@strapi/types';
import { type errors } from '@strapi/utils';
/**
@ -13,6 +13,7 @@ export interface CreateHistoryVersion {
status: 'draft' | 'published' | 'modified' | null;
data: Record<string, unknown>;
schema: Struct.SchemaAttributes;
componentsSchemas: Record<`${string}.${string}`, Struct.SchemaAttributes>;
}
interface Locale {