Property Editors: Improve Missing Property Editor UI and allow save/publish (#20104)

* Initial implementation of non existing property editor

* Adjust `MissingPropertyEditor` to not require registering in PropertyEditorCollection

* Add `MissingPropertyEditor.name` back

* Remove unused dependencies from DataTypeService

* Removed reference to non existing property

* Add parameterless constructor back to MissingPropertyEditor

* Add validation error on document open to property with missing editor

* Update labels

* Removed public editor alias const

* Update src/Umbraco.Web.UI.Client/src/packages/property-editors/missing/manifests.ts

* Add test that checks whether the new MissingPropertyEditor is returned when an editor is not found

* Also check if the editor UI alias is correct in the test

* Apply suggestions from code review

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Share property editor instances between properties

* Only store missing property editors in memory in `ContentMapDefinition.MapValueViewModels()`

* Add value converter for the missing property editor to always return a string (same as the Label did previously)

* Small improvements to code block

* Adjust property validation to accept missing property editors

* Return the current value when trying to update a property with a missing editor

Same logic as for when the property is readonly.

* Fix failing unit tests

* Small fix

* Add unit test

* Remove client validation

* UI adjustments

* Adjustments from code review

* Adjust test

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Laura Neto
2025-09-18 08:55:58 +02:00
committed by GitHub
parent 061be01e89
commit fd0ccc529b
11 changed files with 167 additions and 123 deletions

View File

@@ -526,8 +526,12 @@ internal abstract class ContentEditingServiceBase<TContent, TContentType, TConte
// this should already have been validated by now, so it's OK to throw exceptions here
if (_propertyEditorCollection.TryGet(propertyType.PropertyEditorAlias, out IDataEditor? dataEditor) == false)
{
_logger.LogWarning("Unable to retrieve property value - no data editor found for property editor: {PropertyEditorAlias}", propertyType.PropertyEditorAlias);
return null;
_logger.LogWarning(
"Unable to find property editor {PropertyEditorAlias}, for property {PropertyAlias}. Leaving property value unchanged.",
propertyType.PropertyEditorAlias,
propertyType.Alias);
return content.GetValue(propertyType.Alias, culture, segment);
}
IDataValueEditor dataValueEditor = dataEditor.GetValueEditor();

View File

@@ -22,7 +22,7 @@ public class PropertyValidationService : IPropertyValidationService
private readonly ILanguageService _languageService;
private readonly ContentSettings _contentSettings;
[Obsolete("Use the constructor that accepts ILanguageService and ContentSettings options. Will be removed in V17.")]
[Obsolete("Use the non-obsolete constructor. Will be removed in V17.")]
public PropertyValidationService(
PropertyEditorCollection propertyEditors,
IDataTypeService dataTypeService,
@@ -76,10 +76,9 @@ public class PropertyValidationService : IPropertyValidationService
}
IDataEditor? dataEditor = GetDataEditor(propertyType);
if (dataEditor == null)
if (dataEditor is null)
{
throw new InvalidOperationException("No property editor found by alias " +
propertyType.PropertyEditorAlias);
return [];
}
// only validate culture invariant properties if

View File

@@ -2834,10 +2834,17 @@ export default {
resetUrlLabel: 'Reset',
},
missingEditor: {
title: 'This property type is no longer available.',
description:
'<p><strong>Error!</strong> This property type is no longer available. Please reach out to your administrator.</p>',
"Don't worry, your content is safe and publishing this document won't overwrite it or remove it.<br/>Please contact your site administrator to resolve this issue.",
detailsTitle: 'Additional details',
detailsDescription:
'<p>This property type is no longer available.<br/>Please contact your administrator so they can either delete this property or restore the property type.</p><p><strong>Data:</strong></p>',
"To resolve this you should either restore the property editor, change the property to use a supported data type or remove the property if it's no longer needed.",
detailsDataType: 'Data type',
detailsPropertyEditor: 'Property editor',
detailsData: 'Data',
detailsHide: 'Hide details',
detailsShow: 'Show details',
},
uiCulture: {
ar: 'العربية',

View File

@@ -2832,9 +2832,16 @@ export default {
resetUrlLabel: 'Redefinir',
},
missingEditor: {
title: 'Este tipo de propriedade já não se encontra disponível.',
description:
'<p><strong>Erro!</strong> Este tipo de propriedade já não se encontra disponível. Por favor, contacte o administrador.</p>',
'Não se preocupe, o seu conteúdo está seguro e a publicação deste documento não o substituirá nem removerá.<br/>Entre em contacto com o administrador do site para resolver o problema.',
detailsTitle: 'Detalhes adicionais',
detailsDescription:
'<p>Este tipo de propriedade já não se encontra disponível.<br/>Por favor, contacte o administrador para que ele possa apagar a propriedade ou restaurar o tipo de propriedade.</p><p><strong>Dados:</strong></p>',
'Para resolver o problema, deverá ou restaurar o editor de propriedades, ou alterar a propriedade para usar um tipo de dados compatível ou remover a propriedade se ela não for mais necessária.',
detailsDataType: 'Tipo de dados',
detailsPropertyEditor: 'Editor de propriedades',
detailsData: 'Dados',
detailsHide: 'Esconder detalhes',
detailsShow: 'Mostrar detalhes',
},
} as UmbLocalizationDictionary;

View File

@@ -1,5 +1,3 @@
import { manifests as modalManifests } from './modal/manifests.js';
export const manifests: Array<UmbExtensionManifest> = [
{
type: 'propertyEditorUi',
@@ -14,5 +12,4 @@ export const manifests: Array<UmbExtensionManifest> = [
supportsReadOnly: true,
},
},
...modalManifests,
];

View File

@@ -1 +0,0 @@
export * from './missing-editor-modal.token.js';

View File

@@ -1,8 +0,0 @@
export const manifests: Array<UmbExtensionManifest> = [
{
type: 'modal',
alias: 'Umb.Modal.MissingPropertyEditor',
name: 'Missing Property Editor Modal',
element: () => import('./missing-editor-modal.element.js'),
},
];

View File

@@ -1,47 +0,0 @@
import type { UmbMissingPropertyModalData, UmbMissingPropertyModalResult } from './missing-editor-modal.token.js';
import { html, customElement, css } from '@umbraco-cms/backoffice/external/lit';
import { UmbTextStyles } from '@umbraco-cms/backoffice/style';
import { UmbModalBaseElement } from '@umbraco-cms/backoffice/modal';
import { umbFocus } from '@umbraco-cms/backoffice/lit-element';
@customElement('umb-missing-property-editor-modal')
export class UmbMissingPropertyEditorModalElement extends UmbModalBaseElement<
UmbMissingPropertyModalData,
UmbMissingPropertyModalResult
> {
override render() {
return html`
<uui-dialog-layout class="uui-text" headline=${this.localize.term('general_details')}>
<umb-localize key="missingEditor_detailsDescription"></umb-localize>
<umb-code-block copy id="codeblock">${this.data?.value}</umb-code-block>
<uui-button
slot="actions"
id="close"
label="${this.localize.term('general_close')}"
@click=${this._rejectModal}
${umbFocus()}></uui-button>
</uui-dialog-layout>
`;
}
static override styles = [
UmbTextStyles,
css`
uui-dialog-layout {
max-inline-size: 60ch;
}
#codeblock {
max-height: 300px;
overflow: auto;
}
`,
];
}
export { UmbMissingPropertyEditorModalElement as element };
declare global {
interface HTMLElementTagNameMap {
'umb-missing-property-editor-modal': UmbMissingPropertyEditorModalElement;
}
}

View File

@@ -1,17 +0,0 @@
import { UmbModalToken } from '@umbraco-cms/backoffice/modal';
export interface UmbMissingPropertyModalData {
value: string | undefined;
}
export type UmbMissingPropertyModalResult = undefined;
export const UMB_MISSING_PROPERTY_EDITOR_MODAL = new UmbModalToken<
UmbMissingPropertyModalData,
UmbMissingPropertyModalResult
>('Umb.Modal.MissingPropertyEditor', {
modal: {
type: 'dialog',
size: 'small',
},
});

View File

@@ -1,50 +1,129 @@
import { UMB_MISSING_PROPERTY_EDITOR_MODAL } from './modal/missing-editor-modal.token.js';
import { customElement, html } from '@umbraco-cms/backoffice/external/lit';
import { css, customElement, html, nothing, property, query, state } from '@umbraco-cms/backoffice/external/lit';
import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';
import { umbOpenModal } from '@umbraco-cms/backoffice/modal';
import type { UmbPropertyEditorUiElement } from '@umbraco-cms/backoffice/property-editor';
import { UmbFormControlMixin } from '@umbraco-cms/backoffice/validation';
import { UMB_PROPERTY_TYPE_BASED_PROPERTY_CONTEXT } from '@umbraco-cms/backoffice/content';
import { UmbDataTypeDetailRepository, type UmbDataTypeDetailModel } from '@umbraco-cms/backoffice/data-type';
import type { UmbPropertyTypeModel } from '@umbraco-cms/backoffice/content-type';
/**
* @element umb-property-editor-ui-missing
*/
@customElement('umb-property-editor-ui-missing')
export class UmbPropertyEditorUIMissingElement
extends UmbFormControlMixin<unknown, typeof UmbLitElement>(UmbLitElement, undefined)
implements UmbPropertyEditorUiElement
{
export class UmbPropertyEditorUIMissingElement extends UmbLitElement implements UmbPropertyEditorUiElement {
@property()
value = '';
@state()
private _expanded = false;
@query('#details')
focalPointElement!: HTMLElement;
private _dataTypeDetailModel?: UmbDataTypeDetailModel | undefined;
private _dataTypeDetailRepository = new UmbDataTypeDetailRepository(this);
constructor() {
super();
this.addValidator(
'customError',
() => this.localize.term('errors_propertyHasErrors'),
() => true,
);
this.pristine = false;
this.consumeContext(UMB_PROPERTY_TYPE_BASED_PROPERTY_CONTEXT, (propertyContext) => {
if (!propertyContext?.dataType) return;
this.observe(propertyContext.dataType, (dt) => {
if (!dt?.unique) return;
this._updateEditorAlias(dt);
});
});
}
async #onDetails(event: Event) {
event.stopPropagation();
private async _updateEditorAlias(dataType: UmbPropertyTypeModel['dataType']) {
this.observe(await this._dataTypeDetailRepository.byUnique(dataType.unique), (dataType) => {
this._dataTypeDetailModel = dataType;
});
}
await umbOpenModal(this, UMB_MISSING_PROPERTY_EDITOR_MODAL, {
data: {
// If the value is an object, we stringify it to make sure we can display it properly.
// If it's a primitive value, we just convert it to string.
value: typeof this.value === 'object' ? JSON.stringify(this.value, null, 2) : String(this.value),
},
}).catch(() => undefined);
async #onDetails() {
this._expanded = !this._expanded;
if (this._expanded) {
await this.updateComplete;
this.focalPointElement?.focus();
}
}
override render() {
return html`<umb-localize key="missingEditor_description"></umb-localize>
return html`<uui-box id="info">
<div slot="headline">
<uui-icon id="alert" name="alert"></uui-icon>${this.localize.term('missingEditor_title')}
</div>
<div id="content">
<umb-localize key="missingEditor_description"></umb-localize>
${this._expanded ? this._renderDetails() : nothing}
</div>
<uui-button
id="details-button"
look="secondary"
label=${this.localize.term('general_details')}
@click=${this.#onDetails}></uui-button>`;
compact
label="${this.localize.term(this._expanded ? 'missingEditor_detailsHide' : 'missingEditor_detailsShow')}"
@click=${this.#onDetails}>
<span>${this.localize.term(this._expanded ? 'missingEditor_detailsHide' : 'missingEditor_detailsShow')}</span
><uui-symbol-expand id="expand-symbol" .open=${this._expanded}></uui-symbol-expand>
</uui-button>
</uui-box>`;
}
private _renderDetails() {
return html` <div id="details" tabindex="0">
<umb-localize id="details-title" key="missingEditor_detailsTitle"></umb-localize>
<p>
<umb-localize key="missingEditor_detailsDescription"></umb-localize>
</p>
<p>
<strong><umb-localize key="missingEditor_detailsDataType"></umb-localize></strong>:
<code>${this._dataTypeDetailModel?.name}</code><br />
<strong><umb-localize key="missingEditor_detailsPropertyEditor"></umb-localize></strong>:
<code>${this._dataTypeDetailModel?.editorAlias}</code>
</p>
<umb-code-block id="codeblock" copy language="${this.localize.term('missingEditor_detailsData')}"
>${typeof this.value === 'object' ? JSON.stringify(this.value, null, 2) : String(this.value)}</umb-code-block
>
</div>`;
}
static override styles = [
css`
:host {
display: flex;
flex-direction: column;
gap: var(--uui-size-space-3);
--uui-box-default-padding: 0;
}
#content {
padding: var(--uui-size-space-5);
padding-bottom: var(--uui-size-space-3);
}
#alert {
padding-right: var(--uui-size-space-2);
}
#details-button {
float: right;
}
#details {
margin-top: var(--uui-size-space-5);
}
#details-title {
font-weight: 800;
}
#expand-symbol {
transform: rotate(90deg);
}
#expand-symbol[open] {
transform: rotate(180deg);
}
#codeblock {
max-height: 400px;
display: flex;
flex-direction: column;
}
`,
];
}
export default UmbPropertyEditorUIMissingElement;

View File

@@ -10,6 +10,7 @@ using Umbraco.Cms.Core.Configuration.Models;
using Umbraco.Cms.Core.Dictionary;
using Umbraco.Cms.Core.IO;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Models.Validation;
using Umbraco.Cms.Core.PropertyEditors;
using Umbraco.Cms.Core.PropertyEditors.Validators;
using Umbraco.Cms.Core.Serialization;
@@ -27,16 +28,14 @@ public class PropertyValidationServiceTests
private void MockObjects(out PropertyValidationService validationService, out IDataType dt)
{
var dataTypeService = new Mock<IDataTypeService>();
var dataType = Mock.Of<IDataType>(
x => x.ConfigurationObject == string.Empty // irrelevant but needs a value
&& x.DatabaseType == ValueStorageType.Nvarchar
&& x.EditorAlias == Constants.PropertyEditors.Aliases.TextBox);
var dataType = Mock.Of<IDataType>(x => x.ConfigurationObject == string.Empty // irrelevant but needs a value
&& x.DatabaseType == ValueStorageType.Nvarchar
&& x.EditorAlias == Constants.PropertyEditors.Aliases.TextBox);
dataTypeService.Setup(x => x.GetDataType(It.IsAny<int>())).Returns(() => dataType);
dt = dataType;
// new data editor that returns a TextOnlyValueEditor which will do the validation for the properties
var dataEditor = Mock.Of<IDataEditor>(
x => x.Alias == Constants.PropertyEditors.Aliases.TextBox);
var dataEditor = Mock.Of<IDataEditor>(x => x.Alias == Constants.PropertyEditors.Aliases.TextBox);
Mock.Get(dataEditor).Setup(x => x.GetValueEditor(It.IsAny<object>()))
.Returns(new CustomTextOnlyValueEditor(
new DataEditorAttribute(Constants.PropertyEditors.Aliases.TextBox),
@@ -44,7 +43,15 @@ public class PropertyValidationServiceTests
new SystemTextJsonSerializer(new DefaultJsonSerializerEncoderFactory()),
Mock.Of<IIOHelper>()));
var propEditors = new PropertyEditorCollection(new DataEditorCollection(() => new[] { dataEditor }));
var languageService = new Mock<ILanguageService>();
languageService
.Setup(s => s.GetDefaultIsoCodeAsync())
.ReturnsAsync(() => "en-US");
var propEditors = new PropertyEditorCollection(new DataEditorCollection(() => [dataEditor]));
var contentSettings = new Mock<IOptions<ContentSettings>>();
contentSettings.Setup(x => x.Value).Returns(new ContentSettings());
validationService = new PropertyValidationService(
propEditors,
@@ -52,8 +59,8 @@ public class PropertyValidationServiceTests
Mock.Of<ILocalizedTextService>(),
new ValueEditorCache(),
Mock.Of<ICultureDictionary>(),
Mock.Of<ILanguageService>(),
Mock.Of<IOptions<ContentSettings>>());
languageService.Object,
contentSettings.Object);
}
[Test]
@@ -279,6 +286,23 @@ public class PropertyValidationServiceTests
Assert.AreEqual(4, invalid.Length);
}
[TestCase(null)]
[TestCase(24)]
[TestCase("test")]
[TestCase("{\"test\": true}")]
public void ValidatePropertyValue_Always_Returns_No_Validation_Errors_For_Missing_Editor(object? value)
{
MockObjects(out var validationService, out _);
var p1 = new PropertyType(ShortStringHelper, "Missing.Alias", ValueStorageType.Ntext)
{
Variations = ContentVariation.Nothing,
};
var result = validationService.ValidatePropertyValue(p1, value, PropertyValidationContext.Empty());
Assert.AreEqual(0, result.Count());
}
// used so we can inject a mock - we should fix the base class DataValueEditor to be able to have the ILocalizedTextField passed
// in to create the Requried and Regex validators so we aren't using singletons
private class CustomTextOnlyValueEditor : TextOnlyValueEditor