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:
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: 'العربية',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export * from './missing-editor-modal.token.js';
|
||||
@@ -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'),
|
||||
},
|
||||
];
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user