mirror of
https://github.com/strapi/strapi.git
synced 2025-12-28 23:57:32 +00:00
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:
parent
ab56a0f690
commit
163e91acfe
@ -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} />;
|
||||
};
|
||||
|
||||
/* -------------------------------------------------------------------------------------------------
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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));
|
||||
});
|
||||
});
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user