mirror of
https://github.com/strapi/strapi.git
synced 2026-01-07 12:45:45 +00:00
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:
parent
3699735d69
commit
34fcaa72ed
@ -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.
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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(),
|
||||
},
|
||||
|
||||
@ -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')) {
|
||||
|
||||
@ -42,5 +42,8 @@ export default {
|
||||
target: RELEASE_MODEL_UID,
|
||||
inversedBy: 'actions',
|
||||
},
|
||||
isEntryValid: {
|
||||
type: 'boolean',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
@ -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',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@ -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',
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
@ -17,6 +17,7 @@ export interface ReleaseAction extends Entity {
|
||||
contentType: Common.UID.ContentType;
|
||||
locale?: string;
|
||||
release: Release;
|
||||
isEntryValid: boolean;
|
||||
}
|
||||
|
||||
export interface FormattedReleaseAction extends Entity {
|
||||
|
||||
@ -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[];
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user