Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(history): capture content types history #19343

Merged
merged 10 commits into from Jan 29, 2024
@@ -0,0 +1 @@
export const HISTORY_VERSION_UID = 'plugin::content-manager.history-version';
@@ -1,8 +1,9 @@
import type { Schema } from '@strapi/types';
import { HISTORY_VERSION_UID } from '../constants';

const historyVersion: { schema: Schema.CollectionType } = {
schema: {
uid: 'plugin::content-manager.history-version',
uid: HISTORY_VERSION_UID,
modelName: 'history-version',
globalId: 'ContentManagerHistoryVersion',
kind: 'collectionType',
Expand Down
15 changes: 5 additions & 10 deletions packages/core/content-manager/server/src/history/index.ts
Expand Up @@ -2,6 +2,7 @@ import type { Plugin } from '@strapi/types';
import { controllers } from './controllers';
import { services } from './services';
import { contentTypes } from './content-types';
import { getService } from './utils';

/**
* Check once if the feature is enabled (both license info & feature flag) before loading it,
Expand All @@ -10,19 +11,13 @@ import { contentTypes } from './content-types';
const getFeature = (): Partial<Plugin.LoadedPlugin> => {
// TODO: add license check here when it's ready on the license registry
if (strapi.features.future.isEnabled('history')) {
const register: Plugin.LoadedPlugin['register'] = async () => {
// TODO: remove log once there are actual features
console.log('registering history feature');
};
const bootstrap: Plugin.LoadedPlugin['bootstrap'] = async () => {};
const destroy: Plugin.LoadedPlugin['destroy'] = async () => {};

return {
register,
bootstrap,
bootstrap({ strapi }) {
// Start recording history and saving history versions
getService(strapi, 'history').init();
},
controllers,
services,
destroy,
contentTypes,
};
}
Expand Down
@@ -0,0 +1,168 @@
import type { UID } from '@strapi/types';
import { HISTORY_VERSION_UID } from '../../constants';
import { createHistoryService } from '../history';

const createMock = jest.fn();
const userId = 'user-id';
const fakeDate = new Date('1970-01-01T00:00:00.000Z');

const mockGetRequestContext = jest.fn(() => {
return {
state: {
user: {
id: userId,
},
},
};
});

const mockStrapi = {
db: {
query(uid: UID.ContentType) {
if (uid === HISTORY_VERSION_UID) {
return {
create: createMock,
};
}
},
},
requestContext: {
get: mockGetRequestContext,
},
documents: {
middlewares: {
add: jest.fn(),
},
},
contentType(uid: UID.ContentType) {
if (uid === 'api::article.article') {
return {
attributes: {
title: {
type: 'string',
},
},
};
}
},
};

// @ts-expect-error - we're not mocking the full Strapi object
const historyService = createHistoryService({ strapi: mockStrapi });

describe('history-version service', () => {
afterEach(() => {
jest.useRealTimers();
});

it('inits service only once', () => {
historyService.init();
historyService.init();
expect(mockStrapi.documents.middlewares.add).toHaveBeenCalledTimes(1);
});

it('saves relevant document actions in history', async () => {
const context = {
action: 'create',
uid: 'api::article.article',
options: {
id: 'document-id',
},
params: {
locale: 'fr',
},
};

const next = jest.fn();
await historyService.init();
const historyMiddlewareFunction = mockStrapi.documents.middlewares.add.mock.calls[0][2];

// Check that we don't break the middleware chain
await historyMiddlewareFunction(context, next);
expect(next).toHaveBeenCalledWith(context);

// Create and update actions should be saved in history
expect(createMock).toHaveBeenCalled();
context.action = 'update';
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.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).toHaveBeenCalledWith(context);
});

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',
},
},
status: 'draft' as const,
};

await historyService.createVersion(historyVersionData);
expect(createMock).toHaveBeenCalledWith({
data: {
...historyVersionData,
createdBy: userId,
createdAt: 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',
},
locale: 'en',
relatedDocumentId: 'randomid',
schema: {
title: {
type: 'string',
},
},
status: null,
};

mockGetRequestContext.mockReturnValueOnce(null as any);

await historyService.createVersion(historyVersionData);
expect(createMock).toHaveBeenCalledWith({
data: {
...historyVersionData,
createdBy: undefined,
createdAt: fakeDate,
},
});
});
});
@@ -0,0 +1,76 @@
import type { LoadedStrapi, Entity } from '@strapi/types';
import { omit } from 'lodash/fp';
import { HISTORY_VERSION_UID } from '../constants';

import type { HistoryVersions } from '../../../../shared/contracts';

const createHistoryService = ({ strapi }: { strapi: LoadedStrapi }) => {
/**
* Use the query engine API, not the document service,
* since we'll refactor history version to be just a model instead of a content type.
* TODO: remove this comment once the refactor is done.
*/
const query = strapi.db.query(HISTORY_VERSION_UID);

let isInitialized = false;

return {
async init() {
// Prevent initializing the service twice
if (isInitialized) {
return;
}

strapi.documents?.middlewares.add('_all', '_all', (context, next) => {
remidej marked this conversation as resolved.
Show resolved Hide resolved
// Ignore actions that don't mutate documents
if (!['create', 'update'].includes(context.action)) {
return next(context);
}

// Ignore content types not created by the user
if (!context.uid.startsWith('api::')) {
return next(context);
}

const fieldsToIgnore = [
'createdAt',
'updatedAt',
'publishedAt',
'createdBy',
'updatedBy',
'localizations',
'locale',
'strapi_stage',
'strapi_assignee',
];

// Don't await the creation of the history version to not slow down the request
this.createVersion({
contentType: context.uid,
relatedDocumentId: (context.options as { id: Entity.ID }).id,
locale: context.params.locale,
// TODO: check if drafts should should be "modified" once D&P is ready
status: context.params.status,
data: omit(fieldsToIgnore, context.params.data),
schema: omit(fieldsToIgnore, strapi.contentType(context.uid).attributes),
});

return next(context);
});

isInitialized = true;
},

async createVersion(historyVersionData: HistoryVersions.CreateHistoryVersion) {
await query.create({
data: {
...historyVersionData,
createdAt: new Date(),
createdBy: strapi.requestContext.get()?.state?.user.id,
Comment on lines +79 to +80
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Already discussed on slack but put this here anyway. I don't know how it works now but in v4 these were already set when using content-types, well you get them using an util:

const releaseWithCreatorFields = await setCreatorFields({ user })(releaseData);

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks I'll look more at what this does when I implement the model API

},
});
},
};
};

export { createHistoryService };
@@ -1,3 +1,6 @@
import type { Plugin } from '@strapi/types';
import { createHistoryService } from './history';

export const services: Plugin.LoadedPlugin['services'] = {};
export const services = {
history: createHistoryService,
} satisfies Plugin.LoadedPlugin['services'];
15 changes: 15 additions & 0 deletions packages/core/content-manager/server/src/history/utils.ts
@@ -0,0 +1,15 @@
import { Strapi } from '@strapi/types';
import { type services } from './services';

type HistoryServiceName = keyof typeof import('./services').services;

type HistoryServices = {
[K in HistoryServiceName]: ReturnType<(typeof services)[K]>;
};

function getService<T extends keyof HistoryServices>(strapi: Strapi, name: T) {
// Cast is needed because the return type of strapi.service is too vague
return strapi.service(`plugin::content-manager.${name}`) as HistoryServices[T];
}
remidej marked this conversation as resolved.
Show resolved Hide resolved

export { getService };
14 changes: 14 additions & 0 deletions packages/core/content-manager/shared/contracts/history-versions.ts
@@ -0,0 +1,14 @@
import type { Entity, UID } from '@strapi/types';

/**
* Unlike other Content Manager contracts, history versions can't be created via the API,
remidej marked this conversation as resolved.
Show resolved Hide resolved
* but only by the history service. That's why we export the create type directly here.
*/
export interface CreateHistoryVersion {
contentType: UID.ContentType;
relatedDocumentId: Entity.ID;
locale: string;
status: 'draft' | 'published' | 'modified' | null;
Marc-Roig marked this conversation as resolved.
Show resolved Hide resolved
data: Record<string, unknown>;
schema: Record<string, unknown>;
}
1 change: 1 addition & 0 deletions packages/core/content-manager/shared/contracts/index.ts
Expand Up @@ -6,3 +6,4 @@ export * as Relations from './relations';
export * as SingleTypes from './single-types';
export * as UID from './uid';
export * as ReviewWorkflows from './review-workflows';
export * as HistoryVersions from './history-versions';