feat(content-releases): add status to releases (#19502)

* feat(content-releases): add status to releases

* add docs and fix e2e error

* Update docs/docs/docs/01-core/content-releases/00-intro.md

Co-authored-by: Simone <startae14@gmail.com>

* Update docs/docs/docs/01-core/content-releases/00-intro.md

Co-authored-by: Simone <startae14@gmail.com>

* Update docs/docs/docs/01-core/content-releases/00-intro.md

Co-authored-by: Simone <startae14@gmail.com>

* apply marks feedback

* don't throw error on lifecycle hooks inside releases

* handle when actions are not valid anymore

* await for entry validation on releases edit entry

* check if are changes in content types attributes to revalidate

* fix e2e test

* apply marks feedback

---------

Co-authored-by: Simone <startae14@gmail.com>
This commit is contained in:
Fernando Chávez 2024-02-27 12:57:42 +01:00 committed by GitHub
parent 3699735d69
commit 34fcaa72ed
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 464 additions and 36 deletions

View File

@ -22,3 +22,15 @@ import { useCurrentSidebarCategory } from '@docusaurus/theme-common';
<DocCardList items={useCurrentSidebarCategory().items} />
```
### Release's status
Releases are assigned one of five statuses:
- **Ready**: Indicates that the release is fully prepared for publishing, with no invalid entries present.
- **Blocked**: Release has at least one invalid entry preventing publishing.
- **Empty**: Release contains no entries and cannot be published.
- **Failed**: Indicates that the publishing attempt for the release has encountered an error with no changes since then.
- **Done**: Confirms that the release has been successfully published without encountering any errors.
These statuses are dynamically updated based on actions such as creation, addition/removal of entries, updates, and publishing attempts. They provide a concise overview of release readiness and validity, ensuring smooth operations and data integrity.

View File

@ -197,6 +197,34 @@ Exposes methods to schedule release date for releases.
packages/core/content-releases/server/src/services/scheduling.ts
```
### Release status update triggers:
Considering that retrieving the status of all entries in a release is a heavy operation, we don't fetch it every time a user wants to access a release. Instead, we store the status in a field within the Release Content Type, and we only update it when an action that changes the status is triggered. These actions include:
#### Creating a release:
When creating a release, its status is automatically set to "Empty" as there are no entries initially.
#### Adding an entry to a release:
Upon adding an entry to a release, its status is recalculated to either "Ready" or "Blocked" based on the validity of the added entry.
#### Removing an entry from a release:
After removing an entry from a release, the status is recalculated to determine if the release is now "Ready", "Blocked", or "Empty".
#### Updating a release:
Whenever a release is updated, its status is recalculated based on the validity of the actions performed during the update.
#### Publishing a release:
During the publishing process, if successful, the status changes to "Done"; otherwise, it changes to "Failed".
#### Listening to events on entries:
When an entry is updated or deleted, the status of all releases containing that entry is recalculated to reflect any changes in validity.
## Migrations
We have two migrations that we run every time we sync the content types.

View File

@ -31,7 +31,7 @@ describe('register', () => {
})),
})),
hook: jest.fn(() => ({
register: jest.fn(),
register: jest.fn().mockReturnThis(),
})),
admin: {
services: {
@ -84,6 +84,14 @@ describe('bootstrap', () => {
log: {
error: jest.fn(),
},
contentTypes: {
contentTypeA: {
uid: 'contentTypeA',
},
contentTypeB: {
uid: 'contentTypeB',
},
},
webhookStore: {
addAllowedEvent: jest.fn(),
},

View File

@ -1,27 +1,53 @@
/* eslint-disable @typescript-eslint/no-var-requires */
import type { LoadedStrapi, Entity as StrapiEntity } from '@strapi/types';
import type { Common, LoadedStrapi, Entity as StrapiEntity } from '@strapi/types';
import { ALLOWED_WEBHOOK_EVENTS, RELEASE_ACTION_MODEL_UID } from './constants';
import { getService } from './utils';
import { RELEASE_ACTION_MODEL_UID, RELEASE_MODEL_UID, ALLOWED_WEBHOOK_EVENTS } from './constants';
import { getEntryValidStatus, getService } from './utils';
const { features } = require('@strapi/strapi/dist/utils/ee');
export const bootstrap = async ({ strapi }: { strapi: LoadedStrapi }) => {
if (features.isEnabled('cms-content-releases')) {
const contentTypesWithDraftAndPublish = Object.keys(strapi.contentTypes).filter(
(uid) => strapi.contentTypes[uid]?.options?.draftAndPublish
);
// Clean up release-actions when an entry is deleted
strapi.db.lifecycles.subscribe({
afterDelete(event) {
// @ts-expect-error TODO: lifecycles types looks like are not 100% finished
const { model, result } = event;
// @ts-expect-error TODO: lifecycles types looks like are not 100% finished
if (model.kind === 'collectionType' && model.options?.draftAndPublish) {
const { id } = result;
strapi.db.query(RELEASE_ACTION_MODEL_UID).deleteMany({
where: {
target_type: model.uid,
target_id: id,
},
});
models: contentTypesWithDraftAndPublish,
async afterDelete(event) {
try {
// @ts-expect-error TODO: lifecycles types looks like are not 100% finished
const { model, result } = event;
// @ts-expect-error TODO: lifecycles types looks like are not 100% finished
if (model.kind === 'collectionType' && model.options?.draftAndPublish) {
const { id } = result;
const releases = await strapi.db.query(RELEASE_MODEL_UID).findMany({
where: {
actions: {
target_type: model.uid,
target_id: id,
},
},
});
await strapi.db.query(RELEASE_ACTION_MODEL_UID).deleteMany({
where: {
target_type: model.uid,
target_id: id,
},
});
// We update the status of each release after delete the actions
for (const release of releases) {
getService('release', { strapi }).updateReleaseStatus(release.id);
}
}
} catch (error) {
// If an error happens we don't want to block the delete entry flow, but we log the error
strapi.log.error('Error while deleting release actions after entry delete', { error });
}
},
/**
@ -44,19 +70,87 @@ export const bootstrap = async ({ strapi }: { strapi: LoadedStrapi }) => {
* We make this only after deleteMany is succesfully executed to avoid errors
*/
async afterDeleteMany(event) {
const { model, state } = event;
const entriesToDelete = state.entriesToDelete;
if (entriesToDelete) {
await strapi.db.query(RELEASE_ACTION_MODEL_UID).deleteMany({
where: {
target_type: model.uid,
target_id: {
$in: (entriesToDelete as Array<{ id: StrapiEntity.ID }>).map((entry) => entry.id),
try {
const { model, state } = event;
const entriesToDelete = state.entriesToDelete;
if (entriesToDelete) {
const releases = await strapi.db.query(RELEASE_MODEL_UID).findMany({
where: {
actions: {
target_type: model.uid,
target_id: {
$in: (entriesToDelete as Array<{ id: StrapiEntity.ID }>).map(
(entry) => entry.id
),
},
},
},
},
});
await strapi.db.query(RELEASE_ACTION_MODEL_UID).deleteMany({
where: {
target_type: model.uid,
target_id: {
$in: (entriesToDelete as Array<{ id: StrapiEntity.ID }>).map((entry) => entry.id),
},
},
});
// We update the status of each release after delete the actions
for (const release of releases) {
getService('release', { strapi }).updateReleaseStatus(release.id);
}
}
} catch (error) {
// If an error happens we don't want to block the delete entry flow, but we log the error
strapi.log.error('Error while deleting release actions after entry deleteMany', {
error,
});
}
},
async afterUpdate(event) {
try {
// @ts-expect-error TODO: lifecycles types looks like are not 100% finished
const { model, result } = event;
// @ts-expect-error TODO: lifecycles types looks like are not 100% finished
if (model.kind === 'collectionType' && model.options?.draftAndPublish) {
const isEntryValid = await getEntryValidStatus(
model.uid as Common.UID.ContentType,
result,
{
strapi,
}
);
await strapi.db.query(RELEASE_ACTION_MODEL_UID).update({
where: {
target_type: model.uid,
target_id: result.id,
},
data: {
isEntryValid,
},
});
const releases = await strapi.db.query(RELEASE_MODEL_UID).findMany({
where: {
actions: {
target_type: model.uid,
target_id: result.id,
},
},
});
for (const release of releases) {
getService('release', { strapi }).updateReleaseStatus(release.id);
}
}
} catch (error) {
// If an error happens we don't want to block the update entry flow, but we log the error
strapi.log.error('Error while updating release actions after entry update', { error });
}
},
});
if (strapi.features.future.isEnabled('contentReleasesScheduling')) {

View File

@ -42,5 +42,8 @@ export default {
target: RELEASE_MODEL_UID,
inversedBy: 'actions',
},
isEntryValid: {
type: 'boolean',
},
},
};

View File

@ -32,6 +32,11 @@ export default {
timezone: {
type: 'string',
},
status: {
type: 'enumeration',
enum: ['ready', 'blocked', 'failed', 'done', 'empty'],
required: true,
},
actions: {
type: 'relation',
relation: 'oneToMany',

View File

@ -1,8 +1,12 @@
import type { Schema } from '@strapi/types';
import type { Schema, Common } from '@strapi/types';
import { contentTypes as contentTypesUtils, mapAsync } from '@strapi/utils';
import isEqual from 'lodash/isEqual';
import { difference, keys } from 'lodash';
import { RELEASE_ACTION_MODEL_UID } from '../constants';
import { RELEASE_ACTION_MODEL_UID, RELEASE_MODEL_UID } from '../constants';
import { getPopulatedEntry, getEntryValidStatus, getService } from '../utils';
import { Release } from '../../../shared/contracts/releases';
import { ReleaseAction } from '../../../shared/contracts/release-actions';
interface Input {
oldContentTypes: Record<string, Schema.ContentType>;
@ -51,3 +55,127 @@ export async function deleteActionsOnDeleteContentType({ oldContentTypes, conten
});
}
}
export async function migrateIsValidAndStatusReleases() {
const releasesWithoutStatus = (await strapi.db.query(RELEASE_MODEL_UID).findMany({
where: {
status: null,
releasedAt: null,
},
populate: {
actions: {
populate: {
entry: true,
},
},
},
})) as Release[];
mapAsync(releasesWithoutStatus, async (release: Release) => {
const actions = release.actions;
const notValidatedActions = actions.filter((action) => action.isEntryValid === null);
for (const action of notValidatedActions) {
// We need to check the Action is related to a valid entry because we can't assume this is gonna be always the case
// example: users could make changes directly to their database, or data could be lost
if (action.entry) {
const populatedEntry = await getPopulatedEntry(action.contentType, action.entry.id, {
strapi,
});
if (populatedEntry) {
const isEntryValid = getEntryValidStatus(action.contentType, populatedEntry, { strapi });
await strapi.db.query(RELEASE_ACTION_MODEL_UID).update({
where: {
id: action.id,
},
data: {
isEntryValid,
},
});
}
}
}
return getService('release', { strapi }).updateReleaseStatus(release.id);
});
const publishedReleases = await strapi.db.query(RELEASE_MODEL_UID).findMany({
where: {
status: null,
releasedAt: {
$notNull: true,
},
},
});
mapAsync(publishedReleases, async (release: Release) => {
return strapi.db.query(RELEASE_MODEL_UID).update({
where: {
id: release.id,
},
data: {
status: 'done',
},
});
});
}
export async function revalidateChangedContentTypes({ oldContentTypes, contentTypes }: Input) {
if (oldContentTypes !== undefined && contentTypes !== undefined) {
const contentTypesWithDraftAndPublish = Object.keys(oldContentTypes).filter(
(uid) => oldContentTypes[uid]?.options?.draftAndPublish
);
const releasesAffected = new Set();
mapAsync(contentTypesWithDraftAndPublish, async (contentTypeUID: Common.UID.ContentType) => {
const oldContentType = oldContentTypes[contentTypeUID];
const contentType = contentTypes[contentTypeUID];
// If attributes have changed, we need to revalidate actions because maybe validations rules are different
if (!isEqual(oldContentType?.attributes, contentType?.attributes)) {
const actions = await strapi.db.query(RELEASE_ACTION_MODEL_UID).findMany({
where: {
contentType: contentTypeUID,
},
populate: {
entry: true,
release: true,
},
});
await mapAsync(actions, async (action: ReleaseAction) => {
if (action.entry) {
const populatedEntry = await getPopulatedEntry(contentTypeUID, action.entry.id, {
strapi,
});
if (populatedEntry) {
const isEntryValid = await getEntryValidStatus(contentTypeUID, populatedEntry, {
strapi,
});
releasesAffected.add(action.release.id);
await strapi.db.query(RELEASE_ACTION_MODEL_UID).update({
where: {
id: action.id,
},
data: {
isEntryValid,
},
});
}
}
});
}
}).then(() => {
// We need to update the status of the releases affected
mapAsync(releasesAffected, async (releaseId: Release['id']) => {
return getService('release', { strapi }).updateReleaseStatus(releaseId);
});
});
}
}

View File

@ -5,6 +5,8 @@ import { ACTIONS } from './constants';
import {
deleteActionsOnDeleteContentType,
deleteActionsOnDisableDraftAndPublish,
migrateIsValidAndStatusReleases,
revalidateChangedContentTypes,
} from './migrations';
const { features } = require('@strapi/strapi/dist/utils/ee');
@ -14,6 +16,10 @@ export const register = async ({ strapi }: { strapi: LoadedStrapi }) => {
await strapi.admin.services.permission.actionProvider.registerMany(ACTIONS);
strapi.hook('strapi::content-types.beforeSync').register(deleteActionsOnDisableDraftAndPublish);
strapi.hook('strapi::content-types.afterSync').register(deleteActionsOnDeleteContentType);
strapi
.hook('strapi::content-types.afterSync')
.register(deleteActionsOnDeleteContentType)
.register(revalidateChangedContentTypes)
.register(migrateIsValidAndStatusReleases);
}
};

View File

@ -26,6 +26,11 @@ const baseStrapiMock = {
isEnabled: jest.fn().mockReturnValue(true),
},
},
db: {
query: jest.fn().mockReturnValue({
update: jest.fn(),
}),
},
eventHub: {
emit: jest.fn(),
},
@ -59,6 +64,7 @@ describe('release service', () => {
entityService: {
findOne: jest.fn().mockReturnValue({ id: 1, name: 'test' }),
update: jest.fn().mockReturnValue({ id: 1, name: 'Release name' }),
count: jest.fn(),
},
};
// @ts-expect-error Ignore missing properties
@ -123,6 +129,7 @@ describe('release service', () => {
update: jest
.fn()
.mockReturnValue({ id: 1, name: 'Release name', scheduledAt: scheduledDate }),
count: jest.fn(),
},
};
@ -145,6 +152,7 @@ describe('release service', () => {
entityService: {
findOne: jest.fn().mockReturnValue({ id: 1, name: 'test', scheduledAt: new Date() }),
update: jest.fn().mockReturnValue({ id: 1, name: 'Release name', scheduledAt: null }),
count: jest.fn(),
},
};
@ -182,6 +190,19 @@ describe('release service', () => {
describe('createAction', () => {
it('creates an action', async () => {
const servicesMock = {
'release-validation': {
validateEntryContentType: jest.fn(),
validateUniqueEntry: jest.fn(),
},
'populate-builder': () => ({
default: jest.fn().mockReturnThis(),
populateDeep: jest.fn().mockReturnThis(),
countRelations: jest.fn().mockReturnThis(),
build: jest.fn().mockReturnThis(),
}),
};
const strapiMock = {
...baseStrapiMock,
entityService: {
@ -190,13 +211,21 @@ describe('release service', () => {
entry: { id: 1, contentType: 'api::contentType.contentType' },
}),
findOne: jest.fn().mockReturnValue({ id: 1, name: 'test' }),
count: jest.fn(),
},
plugin: jest.fn().mockReturnValue({
service: jest.fn().mockReturnValue({
validateEntryContentType: jest.fn(),
validateUniqueEntry: jest.fn(),
}),
service: jest
.fn()
.mockImplementation((service: 'release-validation' | 'populate-builder') => {
return servicesMock[service];
}),
}),
db: {
query: jest.fn().mockReturnValue({
findOne: jest.fn().mockReturnValue({ id: 1, name: 'test' }),
update: jest.fn().mockReturnValue({ id: 1, name: 'test' }),
}),
},
};
// @ts-expect-error Ignore missing properties
@ -328,6 +357,7 @@ describe('release service', () => {
'populate-builder': () => ({
default: jest.fn().mockReturnThis(),
populateDeep: jest.fn().mockReturnThis(),
countRelations: jest.fn().mockReturnThis(),
build: jest.fn().mockReturnThis(),
}),
};
@ -335,7 +365,7 @@ describe('release service', () => {
const strapiMock = {
...baseStrapiMock,
db: {
transaction: jest.fn().mockImplementation((cb) => cb()),
transaction: jest.fn().mockImplementation((fn) => fn({ onRollback: jest.fn() })),
},
plugin: jest.fn().mockReturnValue({
service: jest
@ -633,8 +663,12 @@ describe('release service', () => {
db: {
query: jest.fn().mockReturnValue({
delete: jest.fn().mockReturnValue({ id: 1, type: 'publish' }),
update: jest.fn().mockReturnValue({ id: 1, type: 'publish' }),
}),
},
entityService: {
count: jest.fn(),
},
};
// @ts-expect-error Ignore missing properties
@ -726,6 +760,7 @@ describe('release service', () => {
createdBy: mockUser.id,
updatedBy: mockUser.id,
name: 'Release name',
status: 'empty',
},
});
});

View File

@ -24,7 +24,7 @@ import type {
ReleaseActionGroupBy,
} from '../../../shared/contracts/release-actions';
import type { Entity, UserInfo } from '../../../shared/types';
import { getService } from '../utils';
import { getService, getPopulatedEntry, getEntryValidStatus } from '../utils';
export interface Locale extends Entity {
name: string;
@ -77,7 +77,10 @@ const createReleaseService = ({ strapi }: { strapi: LoadedStrapi }) => {
]);
const release = await strapi.entityService.create(RELEASE_MODEL_UID, {
data: releaseWithCreatorFields,
data: {
...releaseWithCreatorFields,
status: 'empty',
},
});
if (
@ -258,6 +261,8 @@ const createReleaseService = ({ strapi }: { strapi: LoadedStrapi }) => {
}
}
this.updateReleaseStatus(id);
strapi.telemetry.send('didUpdateContentRelease');
return updatedRelease;
@ -288,11 +293,15 @@ const createReleaseService = ({ strapi }: { strapi: LoadedStrapi }) => {
const { entry, type } = action;
return strapi.entityService.create(RELEASE_ACTION_MODEL_UID, {
const populatedEntry = await getPopulatedEntry(entry.contentType, entry.id, { strapi });
const isEntryValid = await getEntryValidStatus(entry.contentType, populatedEntry, { strapi });
const releaseAction = await strapi.entityService.create(RELEASE_ACTION_MODEL_UID, {
data: {
type,
contentType: entry.contentType,
locale: entry.locale,
isEntryValid,
entry: {
id: entry.id,
__type: entry.contentType,
@ -302,6 +311,10 @@ const createReleaseService = ({ strapi }: { strapi: LoadedStrapi }) => {
},
populate: { release: { fields: ['id'] }, entry: { fields: ['id'] } },
});
this.updateReleaseStatus(releaseId);
return releaseAction;
},
async findActions(
@ -671,6 +684,14 @@ const createReleaseService = ({ strapi }: { strapi: LoadedStrapi }) => {
});
}
// If transaction failed, change release status to failed
strapi.db.query(RELEASE_MODEL_UID).update({
where: { id: releaseId },
data: {
status: 'failed',
},
});
throw error;
}
},
@ -724,8 +745,57 @@ const createReleaseService = ({ strapi }: { strapi: LoadedStrapi }) => {
);
}
this.updateReleaseStatus(releaseId);
return deletedAction;
},
async updateReleaseStatus(releaseId: Release['id']) {
const [totalActions, invalidActions] = await Promise.all([
this.countActions({
filters: {
release: releaseId,
},
}),
this.countActions({
filters: {
release: releaseId,
isEntryValid: false,
},
}),
]);
if (totalActions > 0) {
if (invalidActions > 0) {
return strapi.db.query(RELEASE_MODEL_UID).update({
where: {
id: releaseId,
},
data: {
status: 'blocked',
},
});
}
return strapi.db.query(RELEASE_MODEL_UID).update({
where: {
id: releaseId,
},
data: {
status: 'ready',
},
});
}
return strapi.db.query(RELEASE_MODEL_UID).update({
where: {
id: releaseId,
},
data: {
status: 'empty',
},
});
},
};
};

View File

@ -1,6 +1,43 @@
import type { Common, Entity } from '@strapi/types';
export const getService = (
name: 'release' | 'release-validation' | 'scheduling' | 'release-action' | 'event-manager',
{ strapi } = { strapi: global.strapi }
) => {
return strapi.plugin('content-releases').service(name);
};
export const getPopulatedEntry = async (
contentTypeUid: Common.UID.ContentType,
entryId: Entity.ID,
{ strapi } = { strapi: global.strapi }
) => {
const populateBuilderService = strapi.plugin('content-manager').service('populate-builder');
// @ts-expect-error - populateBuilderService should be a function but is returning service
const populate = await populateBuilderService(contentTypeUid).populateDeep(Infinity).build();
const entry = await strapi.entityService.findOne(contentTypeUid, entryId, { populate });
return entry;
};
export const getEntryValidStatus = async (
contentTypeUid: Common.UID.ContentType,
entry: { id: Entity.ID; [key: string]: any },
{ strapi } = { strapi: global.strapi }
) => {
try {
// Same function used by entity-manager to validate entries before publishing
await strapi.entityValidator.validateEntityCreation(
strapi.getModel(contentTypeUid),
entry,
undefined,
// @ts-expect-error - FIXME: entity here is unnecessary
entry
);
return true;
} catch {
return false;
}
};

View File

@ -17,6 +17,7 @@ export interface ReleaseAction extends Entity {
contentType: Common.UID.ContentType;
locale?: string;
release: Release;
isEntryValid: boolean;
}
export interface FormattedReleaseAction extends Entity {

View File

@ -8,6 +8,7 @@ export interface Release extends Entity {
name: string;
releasedAt: string | null;
scheduledAt: string | null;
status: 'ready' | 'blocked' | 'failed' | 'done' | 'empty';
// We save scheduledAt always in UTC, but users can set the release in a different timezone to show that in the UI for everyone
timezone: string | null;
actions: ReleaseAction[];