V15: Save the variant before scheduling (#18344)

* feat: adds validation checks and saves a version when scheduling content

* chore: adds mock handler for validate

* docs: adds documentation for umbracoPath

* chore: adds deprecation and todos

* feat: adds a method to output a list format

* test: adds test for list format

* feat: rename to list

* feat: adds localization for scheduling

* feat: adds notifications for publishing by action

* test: fixes naming

* feat: adds notification for publishing in bulk

* feat: fixes todo by adding localization

* feat: adds notification when publishing from workspace

---------

Co-authored-by: leekelleher <leekelleher@gmail.com>
This commit is contained in:
Jacob Overgaard
2025-02-17 17:31:34 +01:00
committed by GitHub
parent e753e2cbf0
commit ed63f278c2
10 changed files with 187 additions and 46 deletions

View File

@@ -1412,14 +1412,14 @@ export default {
cssSavedText: 'Stylesheet saved without any errors',
dataTypeSaved: 'Datatype saved',
dictionaryItemSaved: 'Dictionary item saved',
editContentPublishedHeader: 'Content published',
editContentPublishedHeader: 'Document published',
editContentPublishedText: 'and is visible on the website',
editMultiContentPublishedText: '%0% documents published and visible on the website',
editVariantPublishedText: '%0% published and visible on the website',
editMultiVariantPublishedText: '%0% documents published for languages %1% and visible on the website',
editMultiContentPublishedText: '%0% documents published and are visible on the website',
editVariantPublishedText: '%0% published and is visible on the website',
editMultiVariantPublishedText: '%0% documents published for languages %1% and are visible on the website',
editBlueprintSavedHeader: 'Document Blueprint saved',
editBlueprintSavedText: 'Changes have been successfully saved',
editContentSavedHeader: 'Content saved',
editContentSavedHeader: 'Document saved',
editContentSavedText: 'Remember to publish to make changes visible',
editContentScheduledSavedText: 'A schedule for publishing has been updated',
editVariantSavedText: '%0% saved',

View File

@@ -1412,7 +1412,7 @@ export default {
folderUploadNotAllowed:
'This file is being uploaded as part of a folder, but creating a new folder is not allowed here',
folderCreationNotAllowed: 'Creating a new folder is not allowed here',
contentPublishedFailedByEvent: 'Content could not be published, a 3rd party add-in cancelled the action',
contentPublishedFailedByEvent: 'Document could not be published, a 3rd party add-in cancelled the action',
contentTypeDublicatePropertyType: 'Property type already exists',
contentTypePropertyTypeCreated: 'Property type created',
contentTypePropertyTypeCreatedText: 'Name: %0% <br /> DataType: %1%',
@@ -1426,12 +1426,13 @@ export default {
cssSavedText: 'Stylesheet saved without any errors',
dataTypeSaved: 'Datatype saved',
dictionaryItemSaved: 'Dictionary item saved',
editContentPublishedFailedByParent: 'Content could not be published, because a parent page is not published',
editContentPublishedHeader: 'Content published',
editContentPublishedText: 'and visible on the website',
editContentPublishedFailedByValidation: 'Document could not be published, but we saved it for you',
editContentPublishedFailedByParent: 'Document could not be published, because a parent page is not published',
editContentPublishedHeader: 'Document published',
editContentPublishedText: 'and is visible on the website',
editBlueprintSavedHeader: 'Document Blueprint saved',
editBlueprintSavedText: 'Changes have been successfully saved',
editContentSavedHeader: 'Content saved',
editContentSavedHeader: 'Document saved',
editContentSavedText: 'Remember to publish to make changes visible',
editContentSendToPublish: 'Sent For Approval',
editContentSendToPublishText: 'Changes have been sent for approval',
@@ -1493,10 +1494,11 @@ export default {
cannotCopyInformation: 'Could not copy your system information to the clipboard',
webhookSaved: 'Webhook saved',
operationSavedHeaderReloadUser: 'Saved. To view the changes please reload your browser',
editMultiContentPublishedText: '%0% documents published and visible on the website',
editVariantPublishedText: '%0% published and visible on the website',
editMultiVariantPublishedText: '%0% documents published for languages %1% and visible on the website',
editMultiContentPublishedText: '%0% documents published and are visible on the website',
editVariantPublishedText: '%0% published and is visible on the website',
editMultiVariantPublishedText: '%0% documents published for languages %1% and are visible on the website',
editContentScheduledSavedText: 'A schedule for publishing has been updated',
editContentScheduledNotSavedText: 'The schedule for publishing could not be updated',
editVariantSavedText: '%0% saved',
editVariantSendToPublishText: '%0% changes have been sent for approval',
contentCultureUnpublished: 'Content variation %0% unpublished',

View File

@@ -282,6 +282,16 @@ describe('UmbLocalizeController', () => {
});
});
describe('list format', () => {
it('should return a list with conjunction', () => {
expect(controller.list(['one', 'two', 'three'], { type: 'conjunction' })).to.equal('one, two, and three');
});
it('should return a list with disjunction', () => {
expect(controller.list(['one', 'two', 'three'], { type: 'disjunction' })).to.equal('one, two, or three');
});
});
describe('duration', () => {
it('should return a duration', () => {
const now = new Date('2020-01-01T00:00:00');

View File

@@ -941,8 +941,16 @@ export const data: Array<UmbMockDataTypeModel> = [
{
alias: 'layouts',
value: [
{ icon: 'icon-grid', isSystem: true, name: 'Grid', path: '', selected: true },
{ icon: 'icon-list', isSystem: true, name: 'Table', path: '', selected: true },
{
icon: 'icon-grid',
name: 'Document Grid Collection View',
collectionView: 'Umb.CollectionView.Document.Grid',
},
{
icon: 'icon-list',
name: 'Document Table Collection View',
collectionView: 'Umb.CollectionView.Document.Table',
},
],
},
{ alias: 'icon', value: 'icon-layers' },

View File

@@ -41,6 +41,13 @@ export const detailHandlers = [
return res(ctx.status(200), ctx.json<PagedIReferenceResponseModel>(PagedTrackedReference));
}),
rest.put(umbracoPath(`${UMB_SLUG}/:id/validate`, 'v1.1'), (_req, res, ctx) => {
const id = _req.params.id as string;
if (!id) return res(ctx.status(400));
return res(ctx.status(200));
}),
rest.get(umbracoPath(`${UMB_SLUG}/:id`), (req, res, ctx) => {
const id = req.params.id as string;
if (!id) return res(ctx.status(400));

View File

@@ -1,8 +1,10 @@
// TODO: Rename to something more obvious, naming wise this can mean anything. I suggest: umbracoManagementApiPath()
/**
*
* @param path
* Generates a path to an Umbraco API endpoint.
* @param {string} path - The path to the Umbraco API endpoint.
* @param {string} version - The version of the Umbraco API (default is 'v1').
* @returns {string} The path to the Umbraco API endpoint.
*/
export function umbracoPath(path: string) {
return `/umbraco/management/api/v1${path}`;
export function umbracoPath(path: string, version = 'v1') {
return `/umbraco/management/api/${version}${path}`;
}

View File

@@ -10,6 +10,8 @@ import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api';
import { UMB_MODAL_MANAGER_CONTEXT } from '@umbraco-cms/backoffice/modal';
import { UMB_ACTION_EVENT_CONTEXT } from '@umbraco-cms/backoffice/action';
import { UMB_CURRENT_USER_CONTEXT } from '@umbraco-cms/backoffice/current-user';
import { UMB_NOTIFICATION_CONTEXT } from '@umbraco-cms/backoffice/notification';
import { UmbLocalizationController } from '@umbraco-cms/backoffice/localization-api';
export class UmbPublishDocumentEntityAction extends UmbEntityActionBase<never> {
constructor(host: UmbControllerHost, args: UmbEntityActionArgs<never>) {
@@ -19,6 +21,9 @@ export class UmbPublishDocumentEntityAction extends UmbEntityActionBase<never> {
override async execute() {
if (!this.args.unique) throw new Error('The document unique identifier is missing');
const notificationContext = await this.getContext(UMB_NOTIFICATION_CONTEXT);
const localize = new UmbLocalizationController(this);
const languageRepository = new UmbLanguageCollectionRepository(this._host);
const { data: languageData } = await languageRepository.requestCollection({});
@@ -65,7 +70,15 @@ export class UmbPublishDocumentEntityAction extends UmbEntityActionBase<never> {
if (options.length === 1) {
const variantId = UmbVariantId.Create(documentData.variants[0]);
const publishingRepository = new UmbDocumentPublishingRepository(this._host);
await publishingRepository.publish(this.args.unique, [{ variantId }]);
const { error } = await publishingRepository.publish(this.args.unique, [{ variantId }]);
if (!error) {
notificationContext.peek('positive', {
data: {
headline: localize.term('speechBubbles_editContentPublishedHeader'),
message: localize.term('speechBubbles_editContentPublishedText'),
},
});
}
actionEventContext.dispatchEvent(event);
return;
}
@@ -103,10 +116,24 @@ export class UmbPublishDocumentEntityAction extends UmbEntityActionBase<never> {
if (variantIds.length) {
const publishingRepository = new UmbDocumentPublishingRepository(this._host);
await publishingRepository.publish(
const { error } = await publishingRepository.publish(
this.args.unique,
variantIds.map((variantId) => ({ variantId })),
);
if (!error) {
const documentVariants = documentData.variants.filter((variant) => result.selection.includes(variant.culture!));
notificationContext.peek('positive', {
data: {
headline: localize.term('speechBubbles_editContentPublishedHeader'),
message: localize.term(
'speechBubbles_editVariantPublishedText',
localize.list(documentVariants.map((v) => v.culture ?? v.name)),
),
},
});
}
actionEventContext.dispatchEvent(event);
}
}

View File

@@ -11,6 +11,7 @@ import { UmbLocalizationController } from '@umbraco-cms/backoffice/localization-
import { UMB_ENTITY_CONTEXT } from '@umbraco-cms/backoffice/entity';
import { UMB_ACTION_EVENT_CONTEXT } from '@umbraco-cms/backoffice/action';
import { UmbRequestReloadChildrenOfEntityEvent } from '@umbraco-cms/backoffice/entity-action';
import { UMB_NOTIFICATION_CONTEXT } from '@umbraco-cms/backoffice/notification';
export class UmbDocumentPublishEntityBulkAction extends UmbEntityBulkActionBase<object> {
async execute() {
@@ -18,6 +19,9 @@ export class UmbDocumentPublishEntityBulkAction extends UmbEntityBulkActionBase<
const entityType = entityContext.getEntityType();
const unique = entityContext.getUnique();
const notificationContext = await this.getContext(UMB_NOTIFICATION_CONTEXT);
const localize = new UmbLocalizationController(this);
if (!entityType) throw new Error('Entity type not found');
if (unique === undefined) throw new Error('Entity unique not found');
@@ -46,7 +50,7 @@ export class UmbDocumentPublishEntityBulkAction extends UmbEntityBulkActionBase<
updateDate: null,
segment: null,
scheduledPublishDate: null,
scheduledUnpublishDate: null
scheduledUnpublishDate: null,
},
unique: new UmbVariantId(language.unique, null).toString(),
culture: language.unique,
@@ -79,11 +83,24 @@ export class UmbDocumentPublishEntityBulkAction extends UmbEntityBulkActionBase<
if (confirm !== false) {
const variantId = new UmbVariantId(options[0].language.unique, null);
const publishingRepository = new UmbDocumentPublishingRepository(this._host);
let documentCnt = 0;
for (let i = 0; i < this.selection.length; i++) {
const id = this.selection[i];
await publishingRepository.publish(id, [{ variantId }]);
const { error } = await publishingRepository.publish(id, [{ variantId }]);
if (!error) {
documentCnt++;
}
}
notificationContext.peek('positive', {
data: {
headline: localize.term('speechBubbles_editContentPublishedHeader'),
message: localize.term('speechBubbles_editMultiContentPublishedText', documentCnt),
},
});
eventContext.dispatchEvent(event);
}
return;
@@ -116,13 +133,30 @@ export class UmbDocumentPublishEntityBulkAction extends UmbEntityBulkActionBase<
const repository = new UmbDocumentPublishingRepository(this._host);
if (variantIds.length) {
let documentCnt = 0;
for (const unique of this.selection) {
await repository.publish(
const { error } = await repository.publish(
unique,
variantIds.map((variantId) => ({ variantId })),
);
eventContext.dispatchEvent(event);
if (!error) {
documentCnt++;
}
}
notificationContext.peek('positive', {
data: {
headline: localize.term('speechBubbles_editContentPublishedHeader'),
message: localize.term(
'speechBubbles_editMultiVariantPublishedText',
documentCnt,
localize.list(variantIds.map((v) => v.culture ?? '')),
),
},
});
eventContext.dispatchEvent(event);
}
}
}

View File

@@ -8,6 +8,10 @@ import type { UmbVariantId } from '@umbraco-cms/backoffice/variant';
export class UmbDocumentPublishingRepository extends UmbRepositoryBase {
#init!: Promise<unknown>;
#publishingDataSource: UmbDocumentPublishingServerDataSource;
/**
* @deprecated The calling workspace context should be used instead to show notifications
*/
#notificationContext?: UmbNotificationContext;
constructor(host: UmbControllerHost) {
@@ -36,14 +40,7 @@ export class UmbDocumentPublishingRepository extends UmbRepositoryBase {
if (!variants.length) throw new Error('variant IDs are missing');
await this.#init;
const { error } = await this.#publishingDataSource.publish(unique, variants);
if (!error) {
const notification = { data: { message: `Document published` } };
this.#notificationContext?.peek('positive', notification);
}
return { error };
return this.#publishingDataSource.publish(unique, variants);
}
/**
@@ -62,6 +59,7 @@ export class UmbDocumentPublishingRepository extends UmbRepositoryBase {
if (!error) {
const notification = { data: { message: `Document unpublished` } };
// TODO: Move this to the calling workspace context [JOV]
this.#notificationContext?.peek('positive', notification);
}
@@ -76,7 +74,12 @@ export class UmbDocumentPublishingRepository extends UmbRepositoryBase {
* @param forceRepublish
* @memberof UmbDocumentPublishingRepository
*/
async publishWithDescendants(id: string, variantIds: Array<UmbVariantId>, includeUnpublishedDescendants: boolean, forceRepublish: boolean) {
async publishWithDescendants(
id: string,
variantIds: Array<UmbVariantId>,
includeUnpublishedDescendants: boolean,
forceRepublish: boolean,
) {
if (!id) throw new Error('id is missing');
if (!variantIds) throw new Error('variant IDs are missing');
await this.#init;
@@ -90,6 +93,7 @@ export class UmbDocumentPublishingRepository extends UmbRepositoryBase {
if (!error) {
const notification = { data: { message: `Document published with descendants` } };
// TODO: Move this to the calling workspace context [JOV]
this.#notificationContext?.peek('positive', notification);
}

View File

@@ -25,6 +25,7 @@ import { firstValueFrom } from '@umbraco-cms/backoffice/external/rxjs';
import { observeMultiple } from '@umbraco-cms/backoffice/observable-api';
import { DocumentVariantStateModel } from '@umbraco-cms/backoffice/external/backend-api';
import type { UmbEntityUnique } from '@umbraco-cms/backoffice/entity';
import { UmbLocalizationController } from '@umbraco-cms/backoffice/localization-api';
export class UmbDocumentPublishingWorkspaceContext extends UmbContextBase<UmbDocumentPublishingWorkspaceContext> {
/**
@@ -39,6 +40,8 @@ export class UmbDocumentPublishingWorkspaceContext extends UmbContextBase<UmbDoc
#publishingRepository = new UmbDocumentPublishingRepository(this);
#publishedDocumentData?: UmbDocumentDetailModel;
#currentUnique?: UmbEntityUnique;
#notificationContext?: typeof UMB_NOTIFICATION_CONTEXT.TYPE;
readonly #localize = new UmbLocalizationController(this);
constructor(host: UmbControllerHost) {
super(host, UMB_DOCUMENT_PUBLISHING_WORKSPACE_CONTEXT);
@@ -53,6 +56,10 @@ export class UmbDocumentPublishingWorkspaceContext extends UmbContextBase<UmbDoc
this.#eventContext = context;
}).asPromise(),
]);
this.consumeContext(UMB_NOTIFICATION_CONTEXT, (context) => {
this.#notificationContext = context;
});
}
public async publish() {
@@ -118,17 +125,47 @@ export class UmbDocumentPublishingWorkspaceContext extends UmbContextBase<UmbDoc
if (!variants.length) return;
// TODO: Validate content & Save changes for the selected variants — This was how it worked in v.13 [NL]
const { error } = await this.#publishingRepository.publish(unique, variants);
if (!error) {
// reload the document so all states are updated after the publish operation
await this.#documentWorkspaceContext.reload();
this.#loadAndProcessLastPublished();
const variantIds = variants.map((x) => x.variantId);
const saveData = await this.#documentWorkspaceContext.constructSaveData(variantIds);
await this.#documentWorkspaceContext.runMandatoryValidationForSaveData(saveData);
await this.#documentWorkspaceContext.askServerToValidate(saveData, variantIds);
// request reload of this entity
const structureEvent = new UmbRequestReloadStructureForEntityEvent({ entityType, unique });
this.#eventContext?.dispatchEvent(structureEvent);
}
// TODO: Only validate the specified selection.. [NL]
return this.#documentWorkspaceContext.validateAndSubmit(
async () => {
if (!this.#documentWorkspaceContext) {
throw new Error('Document workspace context is missing');
}
// Save the document before scheduling
await this.#documentWorkspaceContext.performCreateOrUpdate(variantIds, saveData);
// Schedule the document
const { error } = await this.#publishingRepository.publish(unique, variants);
if (error) {
return Promise.reject(error);
}
const notification = { data: { message: this.#localize.term('speechBubbles_editContentScheduledSavedText') } };
this.#notificationContext?.peek('positive', notification);
// reload the document so all states are updated after the publish operation
await this.#documentWorkspaceContext.reload();
this.#loadAndProcessLastPublished();
// request reload of this entity
const structureEvent = new UmbRequestReloadStructureForEntityEvent({ entityType, unique });
this.#eventContext?.dispatchEvent(structureEvent);
},
async () => {
const notificationContext = await this.getContext(UMB_NOTIFICATION_CONTEXT);
notificationContext.peek('danger', {
data: { message: this.#localize.term('speechBubbles_editContentScheduledNotSavedText') },
});
return Promise.reject();
},
);
}
/**
@@ -280,9 +317,8 @@ export class UmbDocumentPublishingWorkspaceContext extends UmbContextBase<UmbDoc
// Notifying that the save was successful, but we did not publish, which is what we want to symbolize here. [NL]
const notificationContext = await this.getContext(UMB_NOTIFICATION_CONTEXT);
// TODO: Get rid of the save notification.
// TODO: Translate this message [NL]
notificationContext.peek('danger', {
data: { message: 'Document was not published, but we saved it for you.' },
data: { message: this.#localize.term('speechBubbles_editContentPublishedFailedByValidation') },
});
// Reject even thought the save was successful, but we did not publish, which is what we want to symbolize here. [NL]
return await Promise.reject();
@@ -308,6 +344,17 @@ export class UmbDocumentPublishingWorkspaceContext extends UmbContextBase<UmbDoc
);
if (!error) {
const variants = saveData.variants.filter((v) => variantIds.some((id) => id.culture === v.culture));
this.#notificationContext?.peek('positive', {
data: {
headline: this.#localize.term('speechBubbles_editContentPublishedHeader'),
message: this.#localize.term(
'speechBubbles_editVariantPublishedText',
this.#localize.list(variants.map((v) => v.culture ?? v.name)),
),
},
});
// reload the document so all states are updated after the publish operation
await this.#documentWorkspaceContext.reload();
this.#loadAndProcessLastPublished();