From 0dd4443b754dc2f578841082aa8292860a283923 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Niels=20Lyngs=C3=B8?= Date: Tue, 25 Mar 2025 15:48:07 +0100 Subject: [PATCH] Feature: validation synchronization as opt in (#18798) * allow for this word * getMessages * split inherit and sync method * sync feature * rename sync report * auto report + impl * remove log * double inheritance test * one more test --- .vscode/settings.json | 5 + .../property-editor-ui-block-grid.element.ts | 1 + .../property-editor-ui-block-list.element.ts | 1 + .../workspace/block-workspace.context.ts | 7 + .../content-detail-workspace-base.ts | 1 + .../context/validation-messages.manager.ts | 12 +- .../controllers/validation.controller.test.ts | 270 +++++++++++++++++- .../controllers/validation.controller.ts | 158 ++++++---- .../submittable-workspace-context-base.ts | 2 +- .../rte/components/rte-base.element.ts | 1 + 10 files changed, 392 insertions(+), 66 deletions(-) create mode 100644 .vscode/settings.json diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000000..3119ae6509 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,5 @@ +{ + "cSpell.words": [ + "unprovide" + ] +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/property-editors/block-grid-editor/property-editor-ui-block-grid.element.ts b/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/property-editors/block-grid-editor/property-editor-ui-block-grid.element.ts index eaf8481af9..b156099843 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/property-editors/block-grid-editor/property-editor-ui-block-grid.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/property-editors/block-grid-editor/property-editor-ui-block-grid.element.ts @@ -95,6 +95,7 @@ export class UmbPropertyEditorUIBlockGridElement if (dataPath) { // Set the data path for the local validation context: this.#validationContext.setDataPath(dataPath); + this.#validationContext.autoReport(); } }, 'observeDataPath', diff --git a/src/Umbraco.Web.UI.Client/src/packages/block/block-list/property-editors/block-list-editor/property-editor-ui-block-list.element.ts b/src/Umbraco.Web.UI.Client/src/packages/block/block-list/property-editors/block-list-editor/property-editor-ui-block-list.element.ts index 00c9ef6204..e916852946 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/block/block-list/property-editors/block-list-editor/property-editor-ui-block-list.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/block/block-list/property-editors/block-list-editor/property-editor-ui-block-list.element.ts @@ -261,6 +261,7 @@ export class UmbPropertyEditorUIBlockListElement if (dataPath) { // Set the data path for the local validation context: this.#validationContext.setDataPath(dataPath); + this.#validationContext.autoReport(); } }, 'observeDataPath', diff --git a/src/Umbraco.Web.UI.Client/src/packages/block/block/workspace/block-workspace.context.ts b/src/Umbraco.Web.UI.Client/src/packages/block/block/workspace/block-workspace.context.ts index b565d4de48..8f34ef7cd5 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/block/block/workspace/block-workspace.context.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/block/block/workspace/block-workspace.context.ts @@ -486,6 +486,13 @@ export class UmbBlockWorkspaceContext (this.#filter ? msgs.filter(this.#filter) : msgs)); - getFilteredMessages(): Array { + getNotFilteredMessages(): Array { + return this.#messages.getValue(); + } + + getMessages(): Array { const msgs = this.#messages.getValue(); return this.#filter ? msgs.filter(this.#filter) : msgs; } @@ -68,12 +72,12 @@ export class UmbValidationMessagesManager { } getHasAnyMessages(): boolean { - return this.getFilteredMessages().length !== 0; + return this.getMessages().length !== 0; } getMessagesOfPathAndDescendant(path: string): Array { //path = path.toLowerCase(); - return this.getFilteredMessages().filter((x) => MatchPathOrDescendantPath(x.path, path)); + return this.getMessages().filter((x) => MatchPathOrDescendantPath(x.path, path)); } messagesOfPathAndDescendant(path: string): Observable> { @@ -99,7 +103,7 @@ export class UmbValidationMessagesManager { } getHasMessagesOfPathAndDescendant(path: string): boolean { //path = path.toLowerCase(); - return this.getFilteredMessages().some( + return this.getMessages().some( (x) => x.path.indexOf(path) === 0 && (x.path.length === path.length || x.path[path.length] === '.' || x.path[path.length] === '['), diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/validation/controllers/validation.controller.test.ts b/src/Umbraco.Web.UI.Client/src/packages/core/validation/controllers/validation.controller.test.ts index 8258b8f498..ef8542916f 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/validation/controllers/validation.controller.test.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/validation/controllers/validation.controller.test.ts @@ -17,16 +17,24 @@ describe('UmbValidationController', () => { ctrl = new UmbValidationController(host); }); + afterEach(() => { + host.destroy(); + }); + describe('Basics', () => { + it('is invalid when holding messages', async () => { + ctrl.messages.addMessage('server', '$.test', 'test'); + + await ctrl.validate().catch(() => undefined); + expect(ctrl.isValid).to.be.false; + }); + it('is valid when holding no messages', async () => { await ctrl.validate().catch(() => undefined); expect(ctrl.isValid).to.be.true; }); - it('is invalid when holding messages', async () => { - ctrl.messages.addMessage('server', '$.test', 'test'); - - await ctrl.validate().catch(() => undefined); + it('is not valid in its initial state', async () => { expect(ctrl.isValid).to.be.false; }); }); @@ -70,14 +78,24 @@ describe('UmbValidationController', () => { beforeEach(() => { child = new UmbValidationController(host); }); + afterEach(() => { + child.destroy(); + }); - it('is invalid when not inherited a message', async () => { + it('is valid despite a child begin created', async () => { + await ctrl.validate().catch(() => undefined); + expect(ctrl.isValid).to.be.true; + expect(ctrl.messages.getHasAnyMessages()).to.be.false; + }); + + it('is valid when not inherited a message', async () => { ctrl.messages.addMessage('server', "$.values[?(@.alias == 'my-other')].value.test", 'test'); child.inheritFrom(ctrl, "$.values[?(@.alias == 'my-property')].value"); await Promise.resolve(); await ctrl.validate().catch(() => undefined); + await child.validate().catch(() => undefined); expect(child.isValid).to.be.true; expect(child.messages.getHasAnyMessages()).to.be.false; }); @@ -89,22 +107,97 @@ describe('UmbValidationController', () => { await ctrl.validate().catch(() => undefined); expect(child.isValid).to.be.false; expect(child.messages.getHasAnyMessages()).to.be.true; - expect(child.messages.getFilteredMessages()?.[0].body).to.be.equal('test-body'); + expect(child.messages.getMessages()?.[0].body).to.be.equal('test-body'); }); - it('is invalid base on a message bubbling up', async () => { + it('is invalid bases on a message from a parent', async () => { ctrl.messages.addMessage('server', "$.values[?(@.alias == 'my-property')].value.test", 'test-body'); child.inheritFrom(ctrl, "$.values[?(@.alias == 'my-property')].value"); + child.autoReport(); await ctrl.validate().catch(() => undefined); expect(ctrl.isValid).to.be.false; expect(ctrl.messages.getHasAnyMessages()).to.be.true; - expect(ctrl.messages.getFilteredMessages()?.[0].body).to.be.equal('test-body'); + expect(ctrl.messages.getMessages()?.[0].body).to.be.equal('test-body'); + }); + + it('is invalid based on a synced message from a child', async () => { + child.inheritFrom(ctrl, "$.values[?(@.alias == 'my-property')].value"); + child.messages.addMessage('server', '$.test', 'test-body'); + child.autoReport(); + + await ctrl.validate().catch(() => undefined); + expect(ctrl.isValid).to.be.false; + expect(ctrl.messages.getHasAnyMessages()).to.be.true; + expect(ctrl.messages.getMessages()?.[0].body).to.be.equal('test-body'); + }); + + it('is invalid based on a syncOnce message from a child', async () => { + child.inheritFrom(ctrl, "$.values[?(@.alias == 'my-property')].value"); + child.messages.addMessage('server', '$.test', 'test-body'); + child.report(); + + await ctrl.validate().catch(() => undefined); + expect(ctrl.isValid).to.be.false; + expect(ctrl.messages.getHasAnyMessages()).to.be.true; + expect(ctrl.messages.getMessages()?.[0].body).to.be.equal('test-body'); + }); + + it('is invalid based on a syncOnce message from a child who later got the message removed.', async () => { + child.inheritFrom(ctrl, "$.values[?(@.alias == 'my-property')].value"); + child.messages.addMessage('server', '$.test', 'test-body', 'test-msg-key'); + child.report(); + child.messages.removeMessageByKey('test-msg-key'); + + await ctrl.validate().catch(() => undefined); + expect(ctrl.isValid).to.be.false; + expect(ctrl.messages.getHasAnyMessages()).to.be.true; + expect(ctrl.messages.getMessages()?.[0].body).to.be.equal('test-body'); + }); + + it('is valid based on a syncOnce message from a child who later got removed and syncOnce.', async () => { + child.inheritFrom(ctrl, "$.values[?(@.alias == 'my-property')].value"); + child.messages.addMessage('server', '$.test', 'test-body', 'test-msg-key'); + child.report(); + child.messages.removeMessageByKey('test-msg-key'); + child.report(); + + await ctrl.validate().catch(() => undefined); + expect(ctrl.isValid).to.be.true; + expect(ctrl.messages.getHasAnyMessages()).to.be.false; + }); + + it('is valid despite child previously had a syncOnce executed', async () => { + child.inheritFrom(ctrl, "$.values[?(@.alias == 'my-property')].value"); + child.report(); + child.messages.addMessage('server', '$.test', 'test-body'); + + expect(child.isValid).to.be.false; + expect(child.messages.getHasAnyMessages()).to.be.true; + expect(child.messages.getNotFilteredMessages()?.[0].body).to.be.equal('test-body'); + + await ctrl.validate().catch(() => undefined); + expect(ctrl.isValid).to.be.true; + expect(ctrl.messages.getHasAnyMessages()).to.be.false; + }); + + it('is still valid despite non-synchronizing child is invalid', async () => { + child.inheritFrom(ctrl, "$.values[?(@.alias == 'my-property')].value"); + child.messages.addMessage('server', '$.test', 'test-body'); + + await ctrl.validate().catch(() => undefined); + await child.validate().catch(() => undefined); + expect(child.isValid).to.be.false; + expect(child.messages.getHasAnyMessages()).to.be.true; + expect(child.messages.getNotFilteredMessages()?.[0].body).to.be.equal('test-body'); + expect(ctrl.isValid).to.be.true; + expect(ctrl.messages.getHasAnyMessages()).to.be.false; }); it('is valid when a message has been removed from a child context', async () => { ctrl.messages.addMessage('server', "$.values[?(@.alias == 'my-property')].value.test", 'test-body'); child.inheritFrom(ctrl, "$.values[?(@.alias == 'my-property')].value"); + child.autoReport(); // First they are invalid: await ctrl.validate().catch(() => undefined); @@ -123,13 +216,27 @@ describe('UmbValidationController', () => { expect(ctrl.messages.getHasAnyMessages()).to.be.false; }); + it('is still invalid despite a message has been removed from a non-synchronizing child context', async () => { + ctrl.messages.addMessage('server', "$.values[?(@.alias == 'my-property')].value.test", 'test-body'); + child.inheritFrom(ctrl, "$.values[?(@.alias == 'my-property')].value"); + child.messages.removeMessagesByPath('$.test'); + + // After the removal they are valid: + await child.validate().catch(() => undefined); + expect(child.isValid).to.be.true; + expect(child.messages.getHasAnyMessages()).to.be.false; + expect(ctrl.isValid).to.be.false; + expect(ctrl.messages.getHasAnyMessages()).to.be.true; + }); + describe('Inheritance + Variant Filter', () => { - it('is invalid when not inherited a message', async () => { + it('is valid when not inherited a message', async () => { child.setVariantId(new UmbVariantId('en-us')); child.inheritFrom( ctrl, "$.values[?(@.alias == 'my-property' && @.culture == 'en-us' && @.segment == null)].value", ); + child.autoReport(); ctrl.messages.addMessage( 'server', @@ -168,6 +275,7 @@ describe('UmbValidationController', () => { 'test-body', ); child.inheritFrom(ctrl, '$'); + child.autoReport(); // First they are invalid: await ctrl.validate().catch(() => undefined); @@ -189,4 +297,148 @@ describe('UmbValidationController', () => { }); }); }); + + describe('Double inheritance', () => { + let child1: UmbValidationController; + let child2: UmbValidationController; + + beforeEach(() => { + child1 = new UmbValidationController(host); + child2 = new UmbValidationController(host); + }); + afterEach(() => { + child1.destroy(); + child2.destroy(); + }); + + it('is auto reporting from two sub contexts', async () => { + ctrl.messages.addMessage('server', "$.values[?(@.alias == 'my-property-1')].value.test", 'test-body-1'); + ctrl.messages.addMessage('server', "$.values[?(@.alias == 'my-property-2')].value.test", 'test-body-2'); + child1.inheritFrom(ctrl, "$.values[?(@.alias == 'my-property-1')].value"); + child2.inheritFrom(ctrl, "$.values[?(@.alias == 'my-property-2')].value"); + child1.autoReport(); + child2.autoReport(); + + // First they are invalid: + await ctrl.validate().catch(() => undefined); + expect(ctrl.isValid).to.be.false; + expect(ctrl.messages.getHasAnyMessages()).to.be.true; + expect(child1.isValid).to.be.false; + expect(child1.messages.getHasAnyMessages()).to.be.true; + expect(child1.messages.getNotFilteredMessages()?.[0].body).to.be.equal('test-body-1'); + expect(child2.isValid).to.be.false; + expect(child2.messages.getHasAnyMessages()).to.be.true; + expect(child2.messages.getNotFilteredMessages()?.[0].body).to.be.equal('test-body-2'); + + child1.messages.removeMessagesByPath('$.test'); + await child1.validate().catch(() => undefined); + + expect(ctrl.isValid).to.be.false; + expect(ctrl.messages.getHasAnyMessages()).to.be.true; + expect(child1.isValid).to.be.true; + expect(child1.messages.getHasAnyMessages()).to.be.false; + expect(child2.isValid).to.be.false; + expect(child2.messages.getHasAnyMessages()).to.be.true; + + child2.messages.removeMessagesByPath('$.test'); + await child2.validate().catch(() => undefined); + + expect(child1.isValid).to.be.true; + expect(child1.messages.getHasAnyMessages()).to.be.false; + expect(child2.isValid).to.be.true; + expect(child2.messages.getHasAnyMessages()).to.be.false; + + await ctrl.validate().catch(() => undefined); + expect(ctrl.isValid, 'root context to be valid').to.be.true; + expect(ctrl.messages.getHasAnyMessages(), 'root context have no messages').to.be.false; + }); + + it('is reporting between two sub context', async () => { + ctrl.messages.addMessage('server', "$.values[?(@.alias == 'my-property')].value.test1", 'test-body-1'); + ctrl.messages.addMessage('server', "$.values[?(@.alias == 'my-property')].value.test2", 'test-body-2'); + child1.inheritFrom(ctrl, "$.values[?(@.alias == 'my-property')].value"); + child2.inheritFrom(ctrl, "$.values[?(@.alias == 'my-property')].value"); + child1.autoReport(); + child2.autoReport(); + + await Promise.resolve(); + // First they are invalid: + await ctrl.validate().catch(() => undefined); + expect(ctrl.isValid).to.be.false; + expect(ctrl.messages.getHasAnyMessages()).to.be.true; + expect(child1.isValid).to.be.false; + expect(child1.messages.getHasAnyMessages()).to.be.true; + expect(child1.messages.getNotFilteredMessages()?.[0].body).to.be.equal('test-body-1'); + expect(child1.messages.getNotFilteredMessages()?.[1].body).to.be.equal('test-body-2'); + expect(child2.isValid).to.be.false; + expect(child2.messages.getHasAnyMessages()).to.be.true; + expect(child2.messages.getNotFilteredMessages()?.[0].body).to.be.equal('test-body-1'); + expect(child2.messages.getNotFilteredMessages()?.[1].body).to.be.equal('test-body-2'); + + child1.messages.removeMessagesByPath('$.test1'); + + expect(ctrl.isValid).to.be.false; + expect(ctrl.messages.getHasAnyMessages()).to.be.true; + expect(child1.isValid).to.be.false; + expect(child1.messages.getHasAnyMessages()).to.be.true; + expect(child1.messages.getNotFilteredMessages()?.[0].body).to.be.equal('test-body-2'); + expect(child2.isValid).to.be.false; + expect(child2.messages.getHasAnyMessages()).to.be.true; + expect(child2.messages.getNotFilteredMessages()?.[0].body).to.be.equal('test-body-2'); + + child2.messages.removeMessagesByPath('$.test2'); + + // Only need to validate the root, because the other controllers are auto reporting. + await ctrl.validate().catch(() => undefined); + + expect(ctrl.isValid, 'root context is valid').to.be.true; + expect(child1.isValid, 'child1 context is valid').to.be.true; + expect(child2.isValid, 'child2 context is valid').to.be.true; + }); + + it('is reporting between two sub context', async () => { + ctrl.messages.addMessage('server', "$.values[?(@.alias == 'my-property')].value.test1", 'test-body-1'); + ctrl.messages.addMessage('server', "$.values[?(@.alias == 'my-property')].value.test2", 'test-body-2'); + child1.inheritFrom(ctrl, "$.values[?(@.alias == 'my-property')].value"); + child2.inheritFrom(ctrl, "$.values[?(@.alias == 'my-property')].value"); + + await Promise.resolve(); + // First they are invalid: + await ctrl.validate().catch(() => undefined); + expect(ctrl.isValid).to.be.false; + expect(ctrl.messages.getHasAnyMessages()).to.be.true; + expect(child1.isValid).to.be.false; + expect(child1.messages.getHasAnyMessages()).to.be.true; + expect(child1.messages.getNotFilteredMessages()?.[0].body).to.be.equal('test-body-1'); + expect(child1.messages.getNotFilteredMessages()?.[1].body).to.be.equal('test-body-2'); + expect(child2.isValid).to.be.false; + expect(child2.messages.getHasAnyMessages()).to.be.true; + expect(child2.messages.getNotFilteredMessages()?.[0].body).to.be.equal('test-body-1'); + expect(child2.messages.getNotFilteredMessages()?.[1].body).to.be.equal('test-body-2'); + + child1.messages.removeMessagesByPath('$.test1'); + child1.report(); + + expect(ctrl.isValid).to.be.false; + expect(ctrl.messages.getHasAnyMessages()).to.be.true; + expect(child1.isValid).to.be.false; + expect(child1.messages.getHasAnyMessages()).to.be.true; + expect(child1.messages.getNotFilteredMessages()?.[0].body).to.be.equal('test-body-2'); + expect(child2.isValid).to.be.false; + expect(child2.messages.getHasAnyMessages()).to.be.true; + expect(child2.messages.getNotFilteredMessages()?.[0].body).to.be.equal('test-body-2'); + + child2.messages.removeMessagesByPath('$.test2'); + child2.report(); + + // We need to validate to the not auto reporting validation controllers updating their isValid state. + await ctrl.validate().catch(() => undefined); + await child1.validate().catch(() => undefined); + await child2.validate().catch(() => undefined); + + expect(ctrl.isValid, 'root context is valid').to.be.true; + expect(child1.isValid, 'child1 context is valid').to.be.true; + expect(child2.isValid, 'child2 context is valid').to.be.true; + }); + }); }); diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/validation/controllers/validation.controller.ts b/src/Umbraco.Web.UI.Client/src/packages/core/validation/controllers/validation.controller.ts index fe2460a23d..e9e4d3721f 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/validation/controllers/validation.controller.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/validation/controllers/validation.controller.ts @@ -9,6 +9,7 @@ import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; import { UmbObjectState } from '@umbraco-cms/backoffice/observable-api'; import { ReplaceStartOfPath } from '../utils/replace-start-of-path.function.js'; import type { UmbVariantId } from '../../variant/variant-id.class.js'; +import { UmbDeprecation } from '../../utils/deprecation/deprecation.js'; const Regex = /@\.culture == ('[^']*'|null) *&& *@\.segment == ('[^']*'|null)/g; @@ -26,15 +27,31 @@ export class UmbValidationController extends UmbControllerBase implements UmbVal >; #inUnprovidingState: boolean = false; + // @reprecated - Will be removed in v.17 // Local version of the data send to the server, only use-case is for translation. #translationData = new UmbObjectState(undefined); + /** + * @deprecated Use extension type 'propertyValidationPathTranslator' instead. Will be removed in v.17 + */ translationDataOf(path: string): any { return this.#translationData.asObservablePart((data) => GetValueByJsonPath(data, path)); } + /** + * @deprecated Use extension type 'propertyValidationPathTranslator' instead. Will be removed in v.17 + */ setTranslationData(data: any): void { this.#translationData.setValue(data); } + /** + * @deprecated Use extension type 'propertyValidationPathTranslator' instead. Will be removed in v.17 + */ getTranslationData(): any { + new UmbDeprecation({ + removeInVersion: '17', + deprecated: 'getTranslationData', + solution: 'getTranslationData is deprecated.', + }).warn(); + return this.#translationData.getValue(); } @@ -43,6 +60,7 @@ export class UmbValidationController extends UmbControllerBase implements UmbVal #isValid: boolean = false; #parent?: UmbValidationController; + #sync?: boolean; #parentMessages?: Array; #localMessages?: Array; #baseDataPath?: string; @@ -135,8 +153,9 @@ export class UmbValidationController extends UmbControllerBase implements UmbVal /** * Define a specific data path for this validation context. * This will turn this validation context into a sub-context of the parent validation context. - * This means that a two-way binding for messages will be established between the parent and the sub-context. - * And it will inherit the Translation Data from its parent. + * This will make this context inherit the messages from the parent validation context. + * @see {@link report} Call `report()` to propagate changes to the parent context. + * @see {@link autoReport} Call `autoReport()` to continuously synchronize changes to the parent context. * * messages and data will be localizes accordingly to the given data path. * @param dataPath {string} - The data path to bind this validation context to. @@ -176,12 +195,14 @@ export class UmbValidationController extends UmbControllerBase implements UmbVal this.#parent.removeValidator(this); } this.#parent = parent; - parent.addValidator(this); + this.#readyToSync(); this.messages.clear(); + this.#localMessages = undefined; this.#baseDataPath = dataPath; + // @deprecated - Will be removed in v.17 this.observe( parent.translationDataOf(dataPath), (data) => { @@ -214,63 +235,90 @@ export class UmbValidationController extends UmbControllerBase implements UmbVal this.messages.addMessage(msg.type, path, msg.body, msg.key); }); } + + this.#localMessages = this.messages.getNotFilteredMessages(); this.messages.finishChange(); }, 'observeParentMessages', ); - - this.observe( - this.messages.messages, - (msgs) => { - if (!this.#parent) return; - - this.#parent!.messages.initiateChange(); - - if (this.#localMessages) { - // Remove the parent messages that does not exist locally anymore: - const toRemove = this.#localMessages.filter((msg) => !msgs.find((m) => m.key === msg.key)); - this.#parent!.messages.removeMessageByKeys(toRemove.map((msg) => msg.key)); - } - this.#localMessages = msgs; - if (this.#baseDataPath === '$') { - this.#parent!.messages.addMessageObjects(msgs); - } else { - msgs.forEach((msg) => { - // replace this.#baseDataPath (if it starts with it) with $ in the path, so it becomes relative to the parent context - const path = ReplaceStartOfPath(msg.path, '$', this.#baseDataPath!); - if (path === undefined) { - throw new Error( - 'Path was not transformed correctly and can therefor not be synced with parent messages.', - ); - } - // Notice, the parent message uses the same key. [NL] - this.#parent!.messages.addMessage(msg.type, path, msg.body, msg.key); - }); - } - - this.#parent!.messages.finishChange(); - }, - 'observeLocalMessages', - ); } #stopInheritance(): void { + this.removeUmbControllerByAlias('observeTranslationData'); + this.removeUmbControllerByAlias('observeParentMessages'); + if (this.#parent) { this.#parent.removeValidator(this); } this.messages.clear(); + this.#localMessages = undefined; this.setTranslationData(undefined); + } - this.removeUmbControllerByAlias('observeTranslationData'); - this.removeUmbControllerByAlias('observeParentMessages'); + #readyToSync() { + if (this.#sync && this.#parent) { + this.#parent.addValidator(this); + } + } + + /** + * Continuously synchronize the messages from this context to the parent context. + */ + autoReport() { + this.#sync = true; + this.#readyToSync(); + this.observe(this.messages.messages, this.#transferMessages, 'observeLocalMessages'); + } + + // no need for this method at this movement. [NL] + /* + #stopSync() { this.removeUmbControllerByAlias('observeLocalMessages'); } + */ + + /** + * Perform a one time transfer of the messages from this context to the parent context. + */ + report(): void { + if (!this.#parent) return; + + if (!this.#sync) { + this.#transferMessages(this.messages.getNotFilteredMessages()); + } + } + + #transferMessages = (msgs: Array) => { + if (!this.#parent) return; + + this.#parent!.messages.initiateChange(); + + if (this.#localMessages) { + // Remove the parent messages that does not exist locally anymore: + const toRemove = this.#localMessages.filter((msg) => !msgs.find((m) => m.key === msg.key)); + this.#parent!.messages.removeMessageByKeys(toRemove.map((msg) => msg.key)); + } + + if (this.#baseDataPath === '$') { + this.#parent!.messages.addMessageObjects(msgs); + } else { + msgs.forEach((msg) => { + // replace this.#baseDataPath (if it starts with it) with $ in the path, so it becomes relative to the parent context + const path = ReplaceStartOfPath(msg.path, '$', this.#baseDataPath!); + if (path === undefined) { + throw new Error('Path was not transformed correctly and can therefor not be synced with parent messages.'); + } + // Notice, the parent message uses the same key. [NL] + this.#parent!.messages.addMessage(msg.type, path, msg.body, msg.key); + }); + } + + this.#parent!.messages.finishChange(); + }; override hostConnected(): void { super.hostConnected(); - if (this.#parent) { - this.#parent.addValidator(this); - } + this.#readyToSync(); } override hostDisconnected(): void { super.hostDisconnected(); @@ -317,7 +365,7 @@ export class UmbValidationController extends UmbControllerBase implements UmbVal this.#validators.splice(index, 1); // If we are in validation mode then we should re-validate to focus next invalid element: if (this.#validationMode) { - this.validate(); + this.validate().catch(() => undefined); } } } @@ -328,23 +376,26 @@ export class UmbValidationController extends UmbControllerBase implements UmbVal * @returns succeed {Promise} - Returns a promise that resolves to true if the validation succeeded. */ async validate(): Promise { - // TODO: clear server messages here?, well maybe only if we know we will get new server messages? Do the server messages hook into the system like another validator? this.#validationMode = true; - const resultsStatus = await Promise.all(this.#validators.map((v) => v.validate())).then( - () => true, - () => false, - ); + const resultsStatus = + this.#validators.length === 0 + ? true + : await Promise.all(this.#validators.map((v) => v.validate())).then( + () => true, + () => false, + ); if (this.#validators.length === 0 && resultsStatus === false) { throw new Error('No validators to validate, but validation failed'); } if (this.messages === undefined) { - // This Context has been destroyed while is was validating, so we should not continue. + // This Context has been destroyed while is was validating, so we should not continue. [NL] return Promise.reject(); } + // We need to ask again for messages, as they might have been added during the validation process. [NL] const hasMessages = this.messages.getHasAnyMessages(); // If we have any messages then we are not valid, otherwise lets check the validation results: [NL] @@ -398,17 +449,20 @@ export class UmbValidationController extends UmbControllerBase implements UmbVal } override destroy(): void { + this.#validationMode = false; if (this.#inUnprovidingState === true) { return; } + this.#destroyValidators(); this.unprovide(); + this.messages?.destroy(); + (this.messages as unknown) = undefined; if (this.#parent) { this.#parent.removeValidator(this); } + this.#localMessages = undefined; + this.#parentMessages = undefined; this.#parent = undefined; - this.#destroyValidators(); - this.messages?.destroy(); - (this.messages as unknown) = undefined; super.destroy(); } } diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/workspace/submittable/submittable-workspace-context-base.ts b/src/Umbraco.Web.UI.Client/src/packages/core/workspace/submittable/submittable-workspace-context-base.ts index 1e988153e2..f018231dd1 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/workspace/submittable/submittable-workspace-context-base.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/workspace/submittable/submittable-workspace-context-base.ts @@ -103,7 +103,7 @@ export abstract class UmbSubmittableWorkspaceContextBase // TODO: Implement developer-mode logging here. [NL] console.warn( 'Validation failed because of these validation messages still begin present: ', - this.#validationContexts.flatMap((x) => x.messages.getFilteredMessages()), + this.#validationContexts.flatMap((x) => x.messages.getMessages()), ); onInvalid(error).then(this.#resolveSubmit, this.#rejectSubmit); }, diff --git a/src/Umbraco.Web.UI.Client/src/packages/rte/components/rte-base.element.ts b/src/Umbraco.Web.UI.Client/src/packages/rte/components/rte-base.element.ts index f9f8a9d5b0..0749d8fbd2 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/rte/components/rte-base.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/rte/components/rte-base.element.ts @@ -145,6 +145,7 @@ export abstract class UmbPropertyEditorUiRteElementBase if (dataPath) { // Set the data path for the local validation context: this.#validationContext.setDataPath(dataPath + '.blocks'); + this.#validationContext.autoReport(); } }, 'observeDataPath',