diff --git a/.vscode/settings.json b/.vscode/settings.json index 1f9ce773f9..f4ca096129 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -22,6 +22,7 @@ "umbraco", "Uncategorized", "uninitialize", + "unprovide", "variantable" ], "exportall.config.folderListener": [], diff --git a/examples/dashboard-with-property-dataset/dataset-dashboard.ts b/examples/dashboard-with-property-dataset/dataset-dashboard.ts index 6f483bd387..20cc50236d 100644 --- a/examples/dashboard-with-property-dataset/dataset-dashboard.ts +++ b/examples/dashboard-with-property-dataset/dataset-dashboard.ts @@ -1,7 +1,7 @@ import { UmbTextStyles } from '@umbraco-cms/backoffice/style'; import { css, html, customElement, LitElement } from '@umbraco-cms/backoffice/external/lit'; import { UmbElementMixin } from '@umbraco-cms/backoffice/element-api'; -import { UmbPropertyValueData, type UmbPropertyDatasetElement } from '@umbraco-cms/backoffice/property'; +import { type UmbPropertyValueData, type UmbPropertyDatasetElement } from '@umbraco-cms/backoffice/property'; @customElement('example-dataset-dashboard') export class ExampleDatasetDashboard extends UmbElementMixin(LitElement) { diff --git a/examples/sorter-with-nested-containers/sorter-group.ts b/examples/sorter-with-nested-containers/sorter-group.ts index a26b40c1c2..38a4c681f9 100644 --- a/examples/sorter-with-nested-containers/sorter-group.ts +++ b/examples/sorter-with-nested-containers/sorter-group.ts @@ -1,7 +1,7 @@ import { UmbTextStyles } from '@umbraco-cms/backoffice/style'; import { css, html, customElement, LitElement, repeat, property } from '@umbraco-cms/backoffice/external/lit'; import { UmbElementMixin } from '@umbraco-cms/backoffice/element-api'; -import { UmbSorterConfig, UmbSorterController } from '@umbraco-cms/backoffice/sorter'; +import { UmbSorterController } from '@umbraco-cms/backoffice/sorter'; import './sorter-item.js'; import ExampleSorterItem from './sorter-item.js'; diff --git a/src/external/lit/index.ts b/src/external/lit/index.ts index e7c9bc62c3..a1f3492898 100644 --- a/src/external/lit/index.ts +++ b/src/external/lit/index.ts @@ -1,6 +1,6 @@ export * from 'lit'; export * from 'lit/decorators.js'; -export { directive, AsyncDirective } from 'lit/async-directive.js'; +export { directive, AsyncDirective, type PartInfo } from 'lit/async-directive.js'; export * from 'lit/directives/class-map.js'; export * from 'lit/directives/if-defined.js'; export * from 'lit/directives/map.js'; diff --git a/src/packages/block/block-grid/components/block-grid-entries/block-grid-entries.element.ts b/src/packages/block/block-grid/components/block-grid-entries/block-grid-entries.element.ts index 36f1be78de..8ecfe7f894 100644 --- a/src/packages/block/block-grid/components/block-grid-entries/block-grid-entries.element.ts +++ b/src/packages/block/block-grid/components/block-grid-entries/block-grid-entries.element.ts @@ -1,3 +1,5 @@ +import { UmbBlockGridEntriesContext } from '../../context/block-grid-entries.context.js'; +import type { UmbBlockGridEntryElement } from '../block-grid-entry/index.js'; import { getAccumulatedValueOfIndex, getInterpolatedIndexOfPositionInWeightMap, @@ -14,13 +16,12 @@ import { type UmbFormControlValidatorConfig, } from '@umbraco-cms/backoffice/validation'; import type { UmbNumberRangeValueType } from '@umbraco-cms/backoffice/models'; -import { UmbBlockGridEntriesContext } from '../../context/block-grid-entries.context.js'; -import type { UmbBlockGridEntryElement } from '../block-grid-entry/index.js'; import type { UmbBlockGridLayoutModel } from '@umbraco-cms/backoffice/block-grid'; /** * Notice this utility method is not really shareable with others as it also takes areas into account. [NL] * @param args + * @returns { null | true } */ function resolvePlacementAsGrid(args: resolvePlacementArgs) { // If this has areas, we do not want to move, unless we are at the edge diff --git a/src/packages/block/block-grid/context/block-grid-entries.context.ts b/src/packages/block/block-grid/context/block-grid-entries.context.ts index f14c1594ac..948c21b35a 100644 --- a/src/packages/block/block-grid/context/block-grid-entries.context.ts +++ b/src/packages/block/block-grid/context/block-grid-entries.context.ts @@ -89,6 +89,20 @@ export class UmbBlockGridEntriesContext return this.#layoutColumns.getValue(); } + getMinAllowed() { + if (this.#areaKey) { + return this.#areaType?.minAllowed ?? 0; + } + return this._manager?.getMinAllowed() ?? 0; + } + + getMaxAllowed() { + if (this.#areaKey) { + return this.#areaType?.maxAllowed ?? Infinity; + } + return this._manager?.getMaxAllowed() ?? Infinity; + } + getLayoutContainerElement() { return this.getHostElement().shadowRoot?.querySelector('.umb-block-grid__layout-container') as | HTMLElement @@ -131,7 +145,7 @@ export class UmbBlockGridEntriesContext data: { entityType: 'block', preset: {}, - originData: { areaKey: this.#areaKey, parentUnique: this.#parentUnique }, + originData: { areaKey: this.#areaKey, parentUnique: this.#parentUnique, baseDataPath: this._dataPath }, }, modal: { size: 'medium' }, }; diff --git a/src/packages/block/block-grid/context/block-grid-manager.context.ts b/src/packages/block/block-grid/context/block-grid-manager.context.ts index 134e48ac35..dfbf764f72 100644 --- a/src/packages/block/block-grid/context/block-grid-manager.context.ts +++ b/src/packages/block/block-grid/context/block-grid-manager.context.ts @@ -7,6 +7,7 @@ import { UMB_APP_CONTEXT } from '@umbraco-cms/backoffice/app'; import type { UmbPropertyEditorConfigCollection } from '@umbraco-cms/backoffice/property-editor'; import { type UmbBlockDataType, UmbBlockManagerContext } from '@umbraco-cms/backoffice/block'; import type { UmbBlockTypeGroup } from '@umbraco-cms/backoffice/block-type'; +import type { UmbNumberRangeValueType } from '@umbraco-cms/backoffice/models'; export const UMB_BLOCK_GRID_DEFAULT_LAYOUT_STYLESHEET = '/umbraco/backoffice/css/umbraco-blockgridlayout.css'; @@ -38,6 +39,16 @@ export class UmbBlockGridManagerContext< return parseInt(value && value !== '' ? value : '12'); }); + getMinAllowed() { + return this._editorConfiguration.getValue()?.getValueByAlias('validationLimit')?.min ?? 0; + } + + getMaxAllowed() { + return ( + this._editorConfiguration.getValue()?.getValueByAlias('validationLimit')?.max ?? Infinity + ); + } + override setEditorConfiguration(configs: UmbPropertyEditorConfigCollection) { this.#initAppUrl.then(() => { // we await initAppUrl, So the appUrl begin here is available when retrieving the layoutStylesheet. diff --git a/src/packages/block/block-grid/property-editors/block-grid-areas-config/property-editor-ui-block-grid-areas-config.element.ts b/src/packages/block/block-grid/property-editors/block-grid-areas-config/property-editor-ui-block-grid-areas-config.element.ts index bfa02c4f6b..56ed2f9a2b 100644 --- a/src/packages/block/block-grid/property-editors/block-grid-areas-config/property-editor-ui-block-grid-areas-config.element.ts +++ b/src/packages/block/block-grid/property-editors/block-grid-areas-config/property-editor-ui-block-grid-areas-config.element.ts @@ -127,10 +127,7 @@ export class UmbPropertyEditorUIBlockGridAreasConfigElement .key=${area.key}>`, )} - ` + ` : ''; } } diff --git a/src/packages/block/block-grid/workspace/block-grid-workspace.modal-token.ts b/src/packages/block/block-grid/workspace/block-grid-workspace.modal-token.ts index fba0224034..4e29db4375 100644 --- a/src/packages/block/block-grid/workspace/block-grid-workspace.modal-token.ts +++ b/src/packages/block/block-grid/workspace/block-grid-workspace.modal-token.ts @@ -17,7 +17,12 @@ export const UMB_BLOCK_GRID_WORKSPACE_MODAL = new UmbModalToken, UmbWorkspaceModalValue>; diff --git a/src/packages/block/block-list/components/block-list-entry/block-list-entry.element.ts b/src/packages/block/block-list/components/block-list-entry/block-list-entry.element.ts index 4d8764fec6..ef2dd27628 100644 --- a/src/packages/block/block-list/components/block-list-entry/block-list-entry.element.ts +++ b/src/packages/block/block-list/components/block-list-entry/block-list-entry.element.ts @@ -10,6 +10,8 @@ import '../inline-list-block/index.js'; import { stringOrStringArrayContains } from '@umbraco-cms/backoffice/utils'; import { UmbBlockListEntryContext } from '../../context/block-list-entry.context.js'; import { UMB_BLOCK_LIST, type UmbBlockListLayoutModel } from '../../types.js'; +import { UmbObserveValidationStateController } from '@umbraco-cms/backoffice/validation'; +import { UmbDataPathBlockElementDataQuery } from '@umbraco-cms/backoffice/block'; /** * @element umb-block-list-entry @@ -33,6 +35,16 @@ export class UmbBlockListEntryElement extends UmbLitElement implements UmbProper if (!value) return; this._contentUdi = value; this.#context.setContentUdi(value); + + new UmbObserveValidationStateController( + this, + `$.contentData[${UmbDataPathBlockElementDataQuery({ udi: value })}]`, + (hasMessages) => { + this._contentInvalid = hasMessages; + this._blockViewProps.contentInvalid = hasMessages; + }, + 'observeMessagesForContent', + ); } private _contentUdi?: string | undefined; @@ -61,6 +73,14 @@ export class UmbBlockListEntryElement extends UmbLitElement implements UmbProper @state() _inlineEditingMode?: boolean; + // 'content-invalid' attribute is used for styling purpose. + @property({ type: Boolean, attribute: 'content-invalid', reflect: true }) + _contentInvalid?: boolean; + + // 'settings-invalid' attribute is used for styling purpose. + @property({ type: Boolean, attribute: 'settings-invalid', reflect: true }) + _settingsInvalid?: boolean; + @state() _blockViewProps: UmbBlockEditorCustomViewProperties = { contentUdi: undefined!, @@ -141,6 +161,20 @@ export class UmbBlockListEntryElement extends UmbLitElement implements UmbProper this.#context.settings, (settings) => { this.#updateBlockViewProps({ settings }); + + this.removeUmbControllerByAlias('observeMessagesForSettings'); + if (settings) { + // Observe settings validation state: + new UmbObserveValidationStateController( + this, + `$.settingsData[${UmbDataPathBlockElementDataQuery(settings)}]`, + (hasMessages) => { + this._settingsInvalid = hasMessages; + this._blockViewProps.settingsInvalid = hasMessages; + }, + 'observeMessagesForSettings', + ); + } }, null, ); @@ -218,16 +252,30 @@ export class UmbBlockListEntryElement extends UmbLitElement implements UmbProper > ${this._showContentEdit && this._workspaceEditContentPath - ? html` + ? html` + ${this._contentInvalid + ? html`!` + : ''} ` : ''} ${this._hasSettings && this._workspaceEditSettingsPath - ? html` + ? html` + ${this._settingsInvalid + ? html`!` + : ''} ` : ''} - this.#context.requestDelete()}> + this.#context.requestDelete()}> @@ -243,15 +291,41 @@ export class UmbBlockListEntryElement extends UmbLitElement implements UmbProper :host { position: relative; display: block; + --umb-block-list-entry-actions-opacity: 0; } + + :host([settings-invalid]), + :host([content-invalid]), + :host(:hover), + :host(:focus-within) { + --umb-block-list-entry-actions-opacity: 1; + } + uui-action-bar { position: absolute; top: var(--uui-size-2); right: var(--uui-size-2); + opacity: var(--umb-block-list-entry-actions-opacity, 0); + transition: opacity 120ms; } :host([drag-placeholder]) { opacity: 0.2; + --umb-block-list-entry-actions-opacity: 0; + } + + :host([settings-invalid])::after, + :host([content-invalid])::after { + content: ''; + position: absolute; + inset: 0; + pointer-events: none; + border: 1px solid var(--uui-color-danger); + border-radius: var(--uui-border-radius); + } + + uui-badge { + z-index: 2; } `, ]; diff --git a/src/packages/block/block-list/components/ref-list-block/ref-list-block.element.ts b/src/packages/block/block-list/components/ref-list-block/ref-list-block.element.ts index 2c167d839a..4e82a91a05 100644 --- a/src/packages/block/block-list/components/ref-list-block/ref-list-block.element.ts +++ b/src/packages/block/block-list/components/ref-list-block/ref-list-block.element.ts @@ -17,7 +17,7 @@ export class UmbRefListBlockElement extends UmbLitElement { @state() _content?: UmbBlockDataType; - @state() + @property() _workspaceEditPath?: string; constructor() { @@ -44,6 +44,7 @@ export class UmbRefListBlockElement extends UmbLitElement { } override render() { + // TODO: apply `slot="name"` to the `umb-ufm-render` element, when UUI supports it. [NL] return html` diff --git a/src/packages/block/block-list/context/block-list-entries.context.ts b/src/packages/block/block-list/context/block-list-entries.context.ts index 8f74c76255..b2108eb02f 100644 --- a/src/packages/block/block-list/context/block-list-entries.context.ts +++ b/src/packages/block/block-list/context/block-list-entries.context.ts @@ -46,7 +46,7 @@ export class UmbBlockListEntriesContext extends UmbBlockEntriesContext< .addUniquePaths(['propertyAlias', 'variantId']) .addAdditionalPath('block') .onSetup(() => { - return { data: { entityType: 'block', preset: {} }, modal: { size: 'medium' } }; + return { data: { entityType: 'block', preset: {}, baseDataPath: this._dataPath }, modal: { size: 'medium' } }; }) .observeRouteBuilder((routeBuilder) => { const newPath = routeBuilder({}); diff --git a/src/packages/block/block-list/property-editors/block-list-editor/property-editor-ui-block-list.element.ts b/src/packages/block/block-list/property-editors/block-list-editor/property-editor-ui-block-list.element.ts index 2a96c2e0c0..45ffa9c275 100644 --- a/src/packages/block/block-list/property-editors/block-list-editor/property-editor-ui-block-list.element.ts +++ b/src/packages/block/block-list/property-editors/block-list-editor/property-editor-ui-block-list.element.ts @@ -1,9 +1,9 @@ +import { UmbBlockListManagerContext } from '../../context/block-list-manager.context.js'; import { UmbBlockListEntriesContext } from '../../context/block-list-entries.context.js'; import type { UmbBlockListLayoutModel, UmbBlockListValueModel } from '../../types.js'; import type { UmbBlockListEntryElement } from '../../components/block-list-entry/index.js'; -import { UmbBlockListManagerContext } from '../../context/block-list-manager.context.js'; import { UMB_BLOCK_LIST_PROPERTY_EDITOR_ALIAS } from './manifests.js'; -import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; +import { UmbLitElement, umbDestroyOnDisconnect } from '@umbraco-cms/backoffice/lit-element'; import { html, customElement, property, state, repeat, css } from '@umbraco-cms/backoffice/external/lit'; import { UmbTextStyles } from '@umbraco-cms/backoffice/style'; import type { UmbPropertyEditorUiElement, UmbBlockTypeBaseModel } from '@umbraco-cms/backoffice/extension-registry'; @@ -15,9 +15,14 @@ import type { UmbNumberRangeValueType } from '@umbraco-cms/backoffice/models'; import type { UmbModalRouteBuilder } from '@umbraco-cms/backoffice/router'; import type { UmbSorterConfig } from '@umbraco-cms/backoffice/sorter'; import { UmbSorterController } from '@umbraco-cms/backoffice/sorter'; -import type { UmbBlockLayoutBaseModel } from '@umbraco-cms/backoffice/block'; +import { + UmbBlockElementDataValidationPathTranslator, + type UmbBlockLayoutBaseModel, +} from '@umbraco-cms/backoffice/block'; import '../../components/block-list-entry/index.js'; +import { UMB_PROPERTY_CONTEXT } from '@umbraco-cms/backoffice/property'; +import { UmbFormControlMixin, UmbValidationContext } from '@umbraco-cms/backoffice/validation'; const SORTER_CONFIG: UmbSorterConfig = { getUniqueOfElement: (element) => { @@ -35,7 +40,10 @@ const SORTER_CONFIG: UmbSorterConfig(UmbLitElement) + implements UmbPropertyEditorUiElement +{ // #sorter = new UmbSorterController(this, { ...SORTER_CONFIG, @@ -44,6 +52,10 @@ export class UmbPropertyEditorUIBlockListElement extends UmbLitElement implement }, }); + #validationContext = new UmbValidationContext(this).provide(); + #contentDataPathTranslator?: UmbBlockElementDataValidationPathTranslator; + #settingsDataPathTranslator?: UmbBlockElementDataValidationPathTranslator; + //#catalogueModal: UmbModalRouteRegistrationController; private _value: UmbBlockListValueModel = { @@ -53,7 +65,7 @@ export class UmbPropertyEditorUIBlockListElement extends UmbLitElement implement }; @property({ attribute: false }) - public set value(value: UmbBlockListValueModel | undefined) { + public override set value(value: UmbBlockListValueModel | undefined) { const buildUpValue: Partial = value ? { ...value } : {}; buildUpValue.layout ??= {}; buildUpValue.contentData ??= []; @@ -64,7 +76,7 @@ export class UmbPropertyEditorUIBlockListElement extends UmbLitElement implement this.#managerContext.setContents(buildUpValue.contentData); this.#managerContext.setSettings(buildUpValue.settingsData); } - public get value(): UmbBlockListValueModel { + public override get value(): UmbBlockListValueModel | undefined { return this._value; } @@ -121,6 +133,44 @@ export class UmbPropertyEditorUIBlockListElement extends UmbLitElement implement constructor() { super(); + this.consumeContext(UMB_PROPERTY_CONTEXT, (context) => { + this.observe( + context.dataPath, + (dataPath) => { + // Translate paths for content elements: + this.#contentDataPathTranslator?.destroy(); + if (dataPath) { + // Set the data path for the local validation context: + this.#validationContext.setDataPath(dataPath); + + this.#contentDataPathTranslator = new UmbBlockElementDataValidationPathTranslator(this, 'contentData'); + } + + // Translate paths for settings elements: + this.#settingsDataPathTranslator?.destroy(); + if (dataPath) { + // Set the data path for the local validation context: + this.#validationContext.setDataPath(dataPath); + + this.#settingsDataPathTranslator = new UmbBlockElementDataValidationPathTranslator(this, 'settingsData'); + } + }, + 'observeDataPath', + ); + }); + + this.addValidator( + 'rangeUnderflow', + () => this.localize.term('validation_entriesShort'), + () => !!this._limitMin && this.#entriesContext.getLength() < this._limitMin, + ); + + this.addValidator( + 'rangeOverflow', + () => this.localize.term('validation_entriesExceed'), + () => !!this._limitMax && this.#entriesContext.getLength() > this._limitMax, + ); + this.observe(this.#entriesContext.layoutEntries, (layouts) => { this._layouts = layouts; // Update sorter. @@ -155,6 +205,10 @@ export class UmbPropertyEditorUIBlockListElement extends UmbLitElement implement this.dispatchEvent(new UmbPropertyValueChangeEvent()); }; + protected override getFormElement() { + return undefined; + } + override render() { let createPath: string | undefined; if (this._blocks?.length === 1) { @@ -171,7 +225,10 @@ export class UmbPropertyEditorUIBlockListElement extends UmbLitElement implement html` - + `, )} diff --git a/src/packages/block/block-list/workspace/block-list-workspace.modal-token.ts b/src/packages/block/block-list/workspace/block-list-workspace.modal-token.ts index e2334ca5a0..89888c14d2 100644 --- a/src/packages/block/block-list/workspace/block-list-workspace.modal-token.ts +++ b/src/packages/block/block-list/workspace/block-list-workspace.modal-token.ts @@ -15,7 +15,7 @@ export const UMB_BLOCK_LIST_WORKSPACE_MODAL = new UmbModalToken, UmbWorkspaceModalValue>; diff --git a/src/packages/block/block-rte/context/block-rte-entries.context.ts b/src/packages/block/block-rte/context/block-rte-entries.context.ts index 3e4f13eb02..4571835198 100644 --- a/src/packages/block/block-rte/context/block-rte-entries.context.ts +++ b/src/packages/block/block-rte/context/block-rte-entries.context.ts @@ -47,7 +47,7 @@ export class UmbBlockRteEntriesContext extends UmbBlockEntriesContext< .addUniquePaths(['propertyAlias', 'variantId']) .addAdditionalPath('block') .onSetup(() => { - return { data: { entityType: 'block', preset: {} }, modal: { size: 'medium' } }; + return { data: { entityType: 'block', preset: {}, baseDataPath: this._dataPath }, modal: { size: 'medium' } }; }) .observeRouteBuilder((routeBuilder) => { const newPath = routeBuilder({}); diff --git a/src/packages/block/block-rte/workspace/block-rte-workspace.modal-token.ts b/src/packages/block/block-rte/workspace/block-rte-workspace.modal-token.ts index 84ca328671..89dbaa9bd7 100644 --- a/src/packages/block/block-rte/workspace/block-rte-workspace.modal-token.ts +++ b/src/packages/block/block-rte/workspace/block-rte-workspace.modal-token.ts @@ -11,7 +11,7 @@ export const UMB_BLOCK_RTE_WORKSPACE_MODAL = new UmbModalToken, UmbWorkspaceModalValue>; diff --git a/src/packages/block/block/context/block-entries.context.ts b/src/packages/block/block/context/block-entries.context.ts index 4f8b41066f..cf84248e6d 100644 --- a/src/packages/block/block/context/block-entries.context.ts +++ b/src/packages/block/block/context/block-entries.context.ts @@ -27,12 +27,18 @@ export abstract class UmbBlockEntriesContext< protected _workspacePath = new UmbStringState(undefined); workspacePath = this._workspacePath.asObservable(); + protected _dataPath?: string; + public abstract readonly canCreate: Observable; protected _layoutEntries = new UmbArrayState([], (x) => x.contentUdi); readonly layoutEntries = this._layoutEntries.asObservable(); readonly layoutEntriesLength = this._layoutEntries.asObservablePart((x) => x.length); + getLength() { + return this._layoutEntries.getValue().length; + } + constructor(host: UmbControllerHost, blockManagerContextToken: BlockManagerContextTokenType) { super(host, UMB_BLOCK_ENTRIES_CONTEXT.toString()); @@ -48,6 +54,10 @@ export abstract class UmbBlockEntriesContext< return this._manager!; } + setDataPath(path: string) { + this._dataPath = path; + } + protected abstract _gotBlockManager(): void; // Public methods: diff --git a/src/packages/block/block/index.ts b/src/packages/block/block/index.ts index 828e8f118c..0e7cb3f0dc 100644 --- a/src/packages/block/block/index.ts +++ b/src/packages/block/block/index.ts @@ -1,4 +1,5 @@ export * from './context/index.js'; export * from './modals/index.js'; export * from './types.js'; +export * from './validation/index.js'; export * from './workspace/index.js'; diff --git a/src/packages/block/block/validation/block-data-property-validation-path-translator.controller.ts b/src/packages/block/block/validation/block-data-property-validation-path-translator.controller.ts new file mode 100644 index 0000000000..096eec3967 --- /dev/null +++ b/src/packages/block/block/validation/block-data-property-validation-path-translator.controller.ts @@ -0,0 +1,28 @@ +import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; +import { + GetPropertyNameFromPath, + UmbDataPathPropertyValueQuery, + UmbValidationPathTranslatorBase, +} from '@umbraco-cms/backoffice/validation'; + +export class UmbBlockElementDataValidationPathTranslator extends UmbValidationPathTranslatorBase { + constructor(host: UmbControllerHost) { + super(host); + } + + translate(path: string) { + if (!this._context) return; + if (path.indexOf('$.') !== 0) { + // We do not handle this path. + return false; + } + + const rest = path.substring(2); + const key = GetPropertyNameFromPath(rest); + + const specificValue = { alias: key }; + // replace the values[ number ] with JSON-Path filter values[@.(...)], continues by the rest of the path: + //return '$.values' + UmbVariantValuesValidationPathTranslator(specificValue) + path.substring(path.indexOf(']')); + return '$.values[' + UmbDataPathPropertyValueQuery(specificValue) + '.value'; + } +} diff --git a/src/packages/block/block/validation/block-data-validation-path-translator.controller.ts b/src/packages/block/block/validation/block-data-validation-path-translator.controller.ts new file mode 100644 index 0000000000..4ba0cf60f0 --- /dev/null +++ b/src/packages/block/block/validation/block-data-validation-path-translator.controller.ts @@ -0,0 +1,23 @@ +import { UmbDataPathBlockElementDataQuery } from './data-path-element-data-query.function.js'; +import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; +import { UmbAbstractArrayValidationPathTranslator } from '@umbraco-cms/backoffice/validation'; + +export class UmbBlockElementDataValidationPathTranslator extends UmbAbstractArrayValidationPathTranslator { + #propertyName: string; + + constructor(host: UmbControllerHost, propertyName: 'contentData' | 'settingsData') { + super(host, '$.' + propertyName + '[', UmbDataPathBlockElementDataQuery); + this.#propertyName = propertyName; + } + + getDataFromIndex(index: number) { + if (!this._context) return; + const data = this._context.getTranslationData(); + const entry = data[this.#propertyName][index]; + if (!entry || !entry.udi) { + console.log('block did not have UDI', this.#propertyName, index, data); + return false; + } + return entry; + } +} diff --git a/src/packages/block/block/validation/data-path-element-data-query.function.ts b/src/packages/block/block/validation/data-path-element-data-query.function.ts new file mode 100644 index 0000000000..c65d92fb82 --- /dev/null +++ b/src/packages/block/block/validation/data-path-element-data-query.function.ts @@ -0,0 +1,15 @@ +import type { UmbBlockDataType } from '../types.js'; + +/** + * Validation Data Path Query generator for Block Element Data. + * write a JSON-Path filter similar to `?(@.udi = 'my-udi://1234')` + * @param udi {string} - The udi of the block Element data. + * @param data {{udi: string}} - A data object with the udi property. + * @returns + */ +export function UmbDataPathBlockElementDataQuery(data: Pick): string { + // write a array of strings for each property, where alias must be present and culture and segment are optional + //const filters: Array = [`@.udi = '${udi}'`]; + //return `?(${filters.join(' && ')})`; + return `?(@.udi = '${data.udi}')`; +} diff --git a/src/packages/block/block/validation/index.ts b/src/packages/block/block/validation/index.ts new file mode 100644 index 0000000000..331352a0d8 --- /dev/null +++ b/src/packages/block/block/validation/index.ts @@ -0,0 +1,2 @@ +export * from './block-data-validation-path-translator.controller.js'; +export * from './data-path-element-data-query.function.js'; diff --git a/src/packages/block/block/workspace/block-element-manager.ts b/src/packages/block/block/workspace/block-element-manager.ts index 54bcd7aeff..be65432e2b 100644 --- a/src/packages/block/block/workspace/block-element-manager.ts +++ b/src/packages/block/block/workspace/block-element-manager.ts @@ -4,8 +4,9 @@ import type { UmbContentTypeModel } from '@umbraco-cms/backoffice/content-type'; import { UmbContentTypeStructureManager } from '@umbraco-cms/backoffice/content-type'; import { UmbObjectState } from '@umbraco-cms/backoffice/observable-api'; import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; -import { UmbControllerBase } from '@umbraco-cms/backoffice/class-api'; +import { type UmbClassInterface, UmbControllerBase } from '@umbraco-cms/backoffice/class-api'; import { UmbDocumentTypeDetailRepository } from '@umbraco-cms/backoffice/document-type'; +import { UmbValidationContext } from '@umbraco-cms/backoffice/validation'; export class UmbBlockElementManager extends UmbControllerBase { // @@ -24,11 +25,17 @@ export class UmbBlockElementManager extends UmbControllerBase { new UmbDocumentTypeDetailRepository(this), ); - constructor(host: UmbControllerHost) { - // TODO: Get Workspace Alias via Manifest. + readonly validation = new UmbValidationContext(this); + + constructor(host: UmbControllerHost, dataPathPropertyName: string) { super(host); this.observe(this.contentTypeId, (id) => this.structure.loadType(id)); + this.observe(this.unique, (udi) => { + if (udi) { + this.validation.setDataPath('$.' + dataPathPropertyName + `[?(@.udi = '${udi}')]`); + } + }); } reset() { @@ -99,6 +106,13 @@ export class UmbBlockElementManager extends UmbControllerBase { return new UmbBlockElementPropertyDatasetContext(host, this); } + public setup(host: UmbClassInterface) { + this.createPropertyDatasetContext(host); + + // Provide Validation Context for this view: + this.validation.provideAt(host); + } + public override destroy(): void { this.#data.destroy(); this.structure.destroy(); diff --git a/src/packages/block/block/workspace/block-workspace.context.ts b/src/packages/block/block/workspace/block-workspace.context.ts index 0464b2fe88..0f1fb6eff3 100644 --- a/src/packages/block/block/workspace/block-workspace.context.ts +++ b/src/packages/block/block/workspace/block-workspace.context.ts @@ -47,11 +47,11 @@ export class UmbBlockWorkspaceContext x?.contentUdi); readonly contentUdi = this.#layout.asObservablePart((x) => x?.contentUdi); - readonly content = new UmbBlockElementManager(this); + readonly content = new UmbBlockElementManager(this, 'contentData'); - readonly settings = new UmbBlockElementManager(this); + readonly settings = new UmbBlockElementManager(this, 'settingsData'); - // TODO: Get the name of the contentElementType.. + // TODO: Get the name from the content element type. Or even better get the Label, but that has to be re-actively updated. #label = new UmbStringState(undefined); readonly name = this.#label.asObservable(); @@ -60,6 +60,9 @@ export class UmbBlockWorkspaceContext { this.#modalContext = context; context.onSubmit().catch(this.#modalRejected); diff --git a/src/packages/block/block/workspace/block-workspace.modal-token.ts b/src/packages/block/block/workspace/block-workspace.modal-token.ts index aaebad4028..701f468ca8 100644 --- a/src/packages/block/block/workspace/block-workspace.modal-token.ts +++ b/src/packages/block/block/workspace/block-workspace.modal-token.ts @@ -3,6 +3,7 @@ import { UmbModalToken } from '@umbraco-cms/backoffice/modal'; export interface UmbBlockWorkspaceData extends UmbWorkspaceModalData { originData: OriginDataType; + baseDataPath: string; } export const UMB_BLOCK_WORKSPACE_MODAL = new UmbModalToken( @@ -12,7 +13,7 @@ export const UMB_BLOCK_WORKSPACE_MODAL = new UmbModalToken, UmbWorkspaceModalValue>; diff --git a/src/packages/block/block/workspace/views/edit/block-workspace-view-edit-properties.element.ts b/src/packages/block/block/workspace/views/edit/block-workspace-view-edit-properties.element.ts index 5487628534..295ee4073e 100644 --- a/src/packages/block/block/workspace/views/edit/block-workspace-view-edit-properties.element.ts +++ b/src/packages/block/block/workspace/views/edit/block-workspace-view-edit-properties.element.ts @@ -32,6 +32,9 @@ export class UmbBlockWorkspaceViewEditPropertiesElement extends UmbLitElement { @state() _propertyStructure: Array = []; + @state() + _dataPaths?: Array; + constructor() { super(); @@ -48,26 +51,36 @@ export class UmbBlockWorkspaceViewEditPropertiesElement extends UmbLitElement { this.#propertyStructureHelper.propertyStructure, (propertyStructure) => { this._propertyStructure = propertyStructure; + this.#generatePropertyDataPath(); }, 'observePropertyStructure', ); } + #generatePropertyDataPath() { + if (!this._propertyStructure) return; + this._dataPaths = this._propertyStructure.map((property) => `$.${property.alias}`); + } + override render() { return repeat( this._propertyStructure, (property) => property.alias, - (property) => html` `, + (property, index) => + html` `, ); } static override styles = [ UmbTextStyles, css` - umb-property-type-based-property { + .property { border-bottom: 1px solid var(--uui-color-divider); } - umb-property-type-based-property:last-child { + .property:last-child { border-bottom: 0; } `, diff --git a/src/packages/block/block/workspace/views/edit/block-workspace-view-edit.element.ts b/src/packages/block/block/workspace/views/edit/block-workspace-view-edit.element.ts index 1677f4ba5b..acc21154e0 100644 --- a/src/packages/block/block/workspace/views/edit/block-workspace-view-edit.element.ts +++ b/src/packages/block/block/workspace/views/edit/block-workspace-view-edit.element.ts @@ -69,8 +69,8 @@ export class UmbBlockWorkspaceViewEditElement extends UmbLitElement implements U const dataManager = this.#blockWorkspace[this.#managerName]; this.#tabsStructureHelper.setStructureManager(dataManager.structure); - // Create Data Set: - dataManager.createPropertyDatasetContext(this); + // Create Data Set & setup Validation Context: + dataManager.setup(this); this.observe( this.#blockWorkspace![this.#managerName!].structure.hasRootContainers('Group'), diff --git a/src/packages/core/components/input-with-alias/input-with-alias.element.ts b/src/packages/core/components/input-with-alias/input-with-alias.element.ts index cc2bb9308e..36e616ae61 100644 --- a/src/packages/core/components/input-with-alias/input-with-alias.element.ts +++ b/src/packages/core/components/input-with-alias/input-with-alias.element.ts @@ -1,19 +1,24 @@ -import { css, customElement, html, property, state } from '@umbraco-cms/backoffice/external/lit'; +import { type PropertyValueMap, css, customElement, html, property, state } from '@umbraco-cms/backoffice/external/lit'; import { generateAlias } from '@umbraco-cms/backoffice/utils'; import { UmbChangeEvent } from '@umbraco-cms/backoffice/event'; -import { UmbFormControlMixin } from '@umbraco-cms/backoffice/validation'; +import { UMB_VALIDATION_EMPTY_LOCALIZATION_KEY, UmbFormControlMixin } from '@umbraco-cms/backoffice/validation'; import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; import { UUIInputEvent } from '@umbraco-cms/backoffice/external/uui'; import type { UUIInputElement } from '@umbraco-cms/backoffice/external/uui'; @customElement('umb-input-with-alias') -export class UmbInputWithAliasElement extends UmbFormControlMixin(UmbLitElement) { +export class UmbInputWithAliasElement extends UmbFormControlMixin( + UmbLitElement, +) { @property({ type: String }) label: string = ''; @property({ type: String, reflect: false }) alias?: string; + @property({ type: Boolean, reflect: true }) + required: boolean = false; + @property({ type: Boolean, reflect: true, attribute: 'alias-readonly' }) aliasReadonly = false; @@ -23,7 +28,15 @@ export class UmbInputWithAliasElement extends UmbFormControlMixin | Map): void { + super.firstUpdated(_changedProperties); + + this.addValidator( + 'valueMissing', + () => UMB_VALIDATION_EMPTY_LOCALIZATION_KEY, + () => this.required && !this.value, + ); + this.shadowRoot?.querySelectorAll('uui-input').forEach((x) => this.addFormControlElement(x)); } @@ -64,6 +77,13 @@ export class UmbInputWithAliasElement extends UmbFormControlMixin + @input=${this.#onNameChange} + ?required=${this.required}> diff --git a/src/packages/core/content-type/structure/content-type-structure-manager.class.ts b/src/packages/core/content-type/structure/content-type-structure-manager.class.ts index f6511f43cc..268bea7841 100644 --- a/src/packages/core/content-type/structure/content-type-structure-manager.class.ts +++ b/src/packages/core/content-type/structure/content-type-structure-manager.class.ts @@ -112,12 +112,14 @@ export class UmbContentTypeStructureManager< if (!contentType || !contentType.unique) throw new Error('Could not find the Content Type to save'); const { error, data } = await this.#repository.save(contentType); - if (error || !data) return { error, data }; + if (error || !data) { + throw error?.message ?? 'Repository did not return data after save.'; + } // Update state with latest version: this.#contentTypes.updateOne(contentType.unique, data); - return { error, data }; + return data; } /** diff --git a/src/packages/core/content/workspace/views/edit/content-editor-properties.element.ts b/src/packages/core/content/workspace/views/edit/content-editor-properties.element.ts index 18173790f5..1399820201 100644 --- a/src/packages/core/content/workspace/views/edit/content-editor-properties.element.ts +++ b/src/packages/core/content/workspace/views/edit/content-editor-properties.element.ts @@ -7,7 +7,7 @@ import type { } from '@umbraco-cms/backoffice/content-type'; import { UmbContentTypePropertyStructureHelper } from '@umbraco-cms/backoffice/content-type'; import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; -import { UmbDataPathPropertyValueFilter } from '@umbraco-cms/backoffice/validation'; +import { UmbDataPathPropertyValueQuery } from '@umbraco-cms/backoffice/validation'; import { UMB_PROPERTY_STRUCTURE_WORKSPACE_CONTEXT } from '@umbraco-cms/backoffice/workspace'; import type { UmbVariantId } from '@umbraco-cms/backoffice/variant'; import { UMB_PROPERTY_DATASET_CONTEXT } from '@umbraco-cms/backoffice/property'; @@ -58,7 +58,7 @@ export class UmbContentWorkspaceViewEditPropertiesElement extends UmbLitElement if (!this.#variantId || !this._propertyStructure) return; this._dataPaths = this._propertyStructure.map( (property) => - `$.values[${UmbDataPathPropertyValueFilter({ + `$.values[${UmbDataPathPropertyValueQuery({ alias: property.alias, culture: property.variesByCulture ? this.#variantId!.culture : null, segment: property.variesBySegment ? this.#variantId!.segment : null, @@ -74,7 +74,7 @@ export class UmbContentWorkspaceViewEditPropertiesElement extends UmbLitElement (property, index) => html` `, ) : ''; diff --git a/src/packages/core/extension-registry/interfaces/block-editor-custom-view-element.interface.ts b/src/packages/core/extension-registry/interfaces/block-editor-custom-view-element.interface.ts index d9b7a73de0..35ed551b97 100644 --- a/src/packages/core/extension-registry/interfaces/block-editor-custom-view-element.interface.ts +++ b/src/packages/core/extension-registry/interfaces/block-editor-custom-view-element.interface.ts @@ -46,6 +46,8 @@ export interface UmbBlockEditorCustomViewProperties< layout?: LayoutType; content?: UmbBlockDataType; settings?: UmbBlockDataType; + contentInvalid?: boolean; + settingsInvalid?: boolean; } export interface UmbBlockEditorCustomViewElement< diff --git a/src/packages/core/extension-registry/interfaces/property-editor-ui-element.interface.ts b/src/packages/core/extension-registry/interfaces/property-editor-ui-element.interface.ts index 8043f4d9b6..39f40cde5b 100644 --- a/src/packages/core/extension-registry/interfaces/property-editor-ui-element.interface.ts +++ b/src/packages/core/extension-registry/interfaces/property-editor-ui-element.interface.ts @@ -3,4 +3,6 @@ import type { UmbPropertyEditorConfigCollection } from '@umbraco-cms/backoffice/ export interface UmbPropertyEditorUiElement extends HTMLElement { value?: unknown; config?: UmbPropertyEditorConfigCollection; + mandatory?: boolean; + mandatoryMessage?: string; } diff --git a/src/packages/core/lit-element/directives/destroy.lit-directive.ts b/src/packages/core/lit-element/directives/destroy.lit-directive.ts new file mode 100644 index 0000000000..3860d3d87a --- /dev/null +++ b/src/packages/core/lit-element/directives/destroy.lit-directive.ts @@ -0,0 +1,38 @@ +import { AsyncDirective, directive, nothing, type ElementPart } from '@umbraco-cms/backoffice/external/lit'; + +/** + * The `focus` directive sets focus on the given element once its connected to the DOM. + */ +class UmbDestroyDirective extends AsyncDirective { + #el?: HTMLElement & { destroy: () => void }; + + override render() { + return nothing; + } + + override update(part: ElementPart) { + this.#el = part.element as any; + return nothing; + } + + override disconnected() { + if (this.#el) { + this.#el.destroy(); + } + this.#el = undefined; + } + + //override reconnected() {} +} + +/** + * @description + * A Lit directive, which destroys the element once its disconnected from the DOM. + * @example: + * ```js + * html``; + * ``` + */ +export const umbDestroyOnDisconnect = directive(UmbDestroyDirective); + +//export type { UmbDestroyDirective }; diff --git a/src/packages/core/lit-element/directives/index.ts b/src/packages/core/lit-element/directives/index.ts index 0ccb1bc7eb..f6877c9c11 100644 --- a/src/packages/core/lit-element/directives/index.ts +++ b/src/packages/core/lit-element/directives/index.ts @@ -1 +1,2 @@ export * from './focus.lit-directive.js'; +export * from './destroy.lit-directive.js'; diff --git a/src/packages/core/modal/token/workspace-modal.token.ts b/src/packages/core/modal/token/workspace-modal.token.ts index f5b7edf84c..27b52e61bf 100644 --- a/src/packages/core/modal/token/workspace-modal.token.ts +++ b/src/packages/core/modal/token/workspace-modal.token.ts @@ -2,9 +2,9 @@ import { UmbModalToken } from './modal-token.js'; export interface UmbWorkspaceModalData { entityType: string; preset: Partial; + baseDataPath?: string; } -// TODO: It would be good with a WorkspaceValueBaseType, to avoid the hardcoded type for unique here: export type UmbWorkspaceModalValue = | { unique: string; diff --git a/src/packages/core/property-type/workspace/property-type-workspace.context.ts b/src/packages/core/property-type/workspace/property-type-workspace.context.ts index 880e05d8c2..a5d949cb39 100644 --- a/src/packages/core/property-type/workspace/property-type-workspace.context.ts +++ b/src/packages/core/property-type/workspace/property-type-workspace.context.ts @@ -16,6 +16,7 @@ import type { ManifestWorkspace } from '@umbraco-cms/backoffice/extension-regist import type { UmbPropertyTypeModel } from '@umbraco-cms/backoffice/content-type'; import { UMB_CONTENT_TYPE_WORKSPACE_CONTEXT } from '@umbraco-cms/backoffice/content-type'; import { UmbId } from '@umbraco-cms/backoffice/id'; +import { UmbValidationContext } from '@umbraco-cms/backoffice/validation'; export class UmbPropertyTypeWorkspaceContext extends UmbSubmittableWorkspaceContextBase @@ -39,6 +40,9 @@ export class UmbPropertyTypeWorkspaceContext { this.#context = instance; - this.observe(instance.data, (data) => { - this._data = data; - }); + this.observe( + instance.data, + (data) => { + this._data = data; + }, + 'observeData', + ); }); this.consumeContext(UMB_CONTENT_TYPE_WORKSPACE_CONTEXT, (instance) => { @@ -176,27 +181,34 @@ export class UmbPropertyTypeWorkspaceViewSettingsElement extends UmbLitElement i return html`
- - - - - - + + + + + + + + +
- + + +
Validation @@ -426,14 +442,11 @@ export class UmbPropertyTypeWorkspaceViewSettingsElement extends UmbLitElement i uui-input { width: 100%; } - #alias-lock { - display: flex; - align-items: center; - justify-content: center; - cursor: pointer; + uui-input:focus-within { + z-index: 1; } - #alias-lock uui-icon { - margin-bottom: 2px; + uui-input-lock:focus-within { + z-index: 1; } .container { display: flex; diff --git a/src/packages/core/property/property/property.context.ts b/src/packages/core/property/property/property.context.ts index 76a0dbaa15..69db32034b 100644 --- a/src/packages/core/property/property/property.context.ts +++ b/src/packages/core/property/property/property.context.ts @@ -44,22 +44,28 @@ export class UmbPropertyContext extends UmbContextBase(undefined); public readonly validation = this.#validation.asObservable(); - private _editor = new UmbBasicState(undefined); - public readonly editor = this._editor.asObservable(); + public readonly validationMandatory = this.#validation.asObservablePart((x) => x?.mandatory); + public readonly validationMandatoryMessage = this.#validation.asObservablePart((x) => x?.mandatoryMessage); + + #dataPath = new UmbStringState(undefined); + public readonly dataPath = this.#dataPath.asObservable(); + + #editor = new UmbBasicState(undefined); + public readonly editor = this.#editor.asObservable(); setEditor(editor: UmbPropertyEditorUiElement | undefined) { - this._editor.setValue(editor ?? undefined); + this.#editor.setValue(editor ?? undefined); } getEditor() { - return this._editor.getValue(); + return this.#editor.getValue(); } // property variant ID: #variantId = new UmbClassState(undefined); public readonly variantId = this.#variantId.asObservable(); - private _variantDifference = new UmbStringState(undefined); - public readonly variantDifference = this._variantDifference.asObservable(); + #variantDifference = new UmbStringState(undefined); + public readonly variantDifference = this.#variantDifference.asObservable(); #datasetContext?: typeof UMB_PROPERTY_DATASET_CONTEXT.TYPE; @@ -72,17 +78,29 @@ export class UmbPropertyContext extends UmbContextBase { - this._observeProperty(); - }); + this.observe( + this.alias, + () => { + this._observeProperty(); + }, + null, + ); - this.observe(this.configValues, (configValues) => { - this.#config.setValue(configValues ? new UmbPropertyEditorConfigCollection(configValues) : undefined); - }); + this.observe( + this.configValues, + (configValues) => { + this.#config.setValue(configValues ? new UmbPropertyEditorConfigCollection(configValues) : undefined); + }, + null, + ); - this.observe(this.variantId, () => { - this._generateVariantDifferenceString(); - }); + this.observe( + this.variantId, + () => { + this._generateVariantDifferenceString(); + }, + null, + ); } private async _observeProperty(): Promise { @@ -109,9 +127,20 @@ export class UmbPropertyContext extends UmbContextBase extends UmbContextBase { this._invalid = invalid; }); } public get dataPath(): string | undefined { - return this.#dataPath; + return this.#propertyContext.getDataPath(); } - #dataPath?: string; @state() private _variantDifference?: string; @@ -172,7 +171,7 @@ export class UmbPropertyElement extends UmbLitElement { #propertyContext = new UmbPropertyContext(this); #controlValidator?: UmbFormControlValidator; - #validationMessageBinder?: UmbBindValidationMessageToFormControl; + #validationMessageBinder?: UmbBindServerValidationToFormControl; #valueObserver?: UmbObserverController; #configObserver?: UmbObserverController; @@ -220,9 +219,12 @@ export class UmbPropertyElement extends UmbLitElement { ); this.observe( - this.#propertyContext.validation, - (validation) => { - this._mandatory = validation?.mandatory; + this.#propertyContext.validationMandatory, + (mandatory) => { + this._mandatory = mandatory; + if (this._element) { + this._element.mandatory = mandatory; + } }, null, ); @@ -281,6 +283,8 @@ export class UmbPropertyElement extends UmbLitElement { if (this._element) { this._element.addEventListener('change', this._onPropertyEditorChange as any as EventListener); this._element.addEventListener('property-value-change', this._onPropertyEditorChange as any as EventListener); + // No need to observe mandatory, as we already do so and set it on the _element if present: [NL] + this._element.mandatory = this._mandatory; // No need for a controller alias, as the clean is handled via the observer prop: this.#valueObserver = this.observe( @@ -303,15 +307,25 @@ export class UmbPropertyElement extends UmbLitElement { }, null, ); + this.#configObserver = this.observe( + this.#propertyContext.validationMandatoryMessage, + (mandatoryMessage) => { + if (mandatoryMessage) { + this._element!.mandatoryMessage = mandatoryMessage ?? undefined; + } + }, + null, + ); if ('checkValidity' in this._element) { - this.#controlValidator = new UmbFormControlValidator(this, this._element as any, this.#dataPath); + const dataPath = this.dataPath; + this.#controlValidator = new UmbFormControlValidator(this, this._element as any, dataPath); // We trust blindly that the dataPath is available at this stage. [NL] - if (this.#dataPath) { - this.#validationMessageBinder = new UmbBindValidationMessageToFormControl( + if (dataPath) { + this.#validationMessageBinder = new UmbBindServerValidationToFormControl( this, this._element as any, - this.#dataPath, + dataPath, ); this.#validationMessageBinder.value = this.#propertyContext.getValue(); } diff --git a/src/packages/core/validation/README.md b/src/packages/core/validation/README.md new file mode 100644 index 0000000000..90bdcc7220 --- /dev/null +++ b/src/packages/core/validation/README.md @@ -0,0 +1,129 @@ +# Backoffice Validation System + +The validation system works around a system of Validation Messages, provided via Validation Contexts and connected to the application via Validators. + +The system both supports handling front-end validation, server-validation and other things can as well be hooked into it. + +## Validation Context + +The core of the system is a Validation Context, this holds the messages and more. + +## Validation Messages + +A Validation message consist of a type, path and body. This typically looks like this: + +``` +{ + type: "client", + path: "$.values[?(@.alias = 'my-property-alias')].value", + message: "Must contain at least 3 words" +} +``` + +Because each validation issue is presented in the Validation Context as a Message, its existence will be available for anyone to observe. +One benefit of this is that Elements that are removed from screen can still have their validation messages preventing the submission of a dataset. +As well Tabs and other navigation can use this to be highlighted, so the user can be guide to the location. + +### Path aka. Data Path + +The Path also named Data Path, A Path pointing to the related data in the model. +A massage uses this to point to the location in the model that the message is concerned. + +The following models headline can be target with this path: + +Data: +``` +{ + settings: { + title: 'too short' + } +} +``` + +JsonPath: +``` +"$.settings.title" +``` + +The following example shows how we use JsonPath Queries to target entries of an array: + +Data: +``` +{ + values: [ + { + alias: 'my-alias', + value: 'my-value' + } + ] +} +``` + +JsonPath: +``` +"$.values.[?(@.alias = 'my-alias')].value" +``` + +Paths are based on JSONPath, using JSON Path Queries when looking up data of an Array. Using Queries enables Paths to not point to specific index, but what makes a entry unique. + +Messages are set via Validators, which is the glue between a the context and a validation source. + +## Validators + +Messages can be set by Validators, a Validator gets assigned to the Validation Context. Making the Context aware about the Validators. + +When the validation context is asked to Validate, it can then call the `validate` method on all the Validators. + +The Validate method can be async, meaning it can request the server or other way figure out its state before resolving. + +We provide a few built in Validators which handles most cases. + +### Form Control Validator + +This Validator binds a Form Control Element with the Validation Context. When the Form Control becomes Invalid, its Validation Message is appended to the Validation Context. + +Notice this one also comes as a Lit Directive called `umbBindToValidation`. + +Also notice this does not bind server validation to the Form Control, see `UmbBindServerValidationToFormControl` + +### Server Model Validator + +This Validator can asks a end-point for validation of the model. + +The Server Model Validator stores the data that was send to the server on the Validation Context. This is then later used by Validation Path Translators to convert index based paths to Json Path Queries. + +This is needed to allow the user to make changes to the data, without loosing the accuracy of the messages coming from server validation. + +## Validation Path Translator + +Validation Path Translator translate Message Paths into a format that is independent of the actual current data. But compatible with mutations of that data model. +This enables the user to retrieve validation messages from the server, and then the user can insert more items and still have the validation appearing in the right spots. +This would not be possible with index based paths, which is why we translate those into JSON Path Queries. + +Such conversation could be from this path: +``` +"$.values.[5].value" +``` + +To this path: +``` +"$.values.[?(@.alias = 'my-alias')].value" +``` + +Once this path is converted to use Json Path Queries, the Data can be changed. The concerned entry might get another index. Without that affecting the accuracy of the path. + +### Late registered Path Translators + +Translators can be registered late. This means that a Property Editor that has a complex value structure, can register a Path Translator for its part of the data. Such Translator will appear late because the Property might not be rendered in the users current view, but first when the user navigates there. +This is completely fine, as messages can be partly translated and then enhanced by late coming Path Translators. + +This fact enables a property to observe if there is any Message Paths that start with the same path as the Data Path for the Property. In this was a property can know that it contains a Validation Message without the Message Path begin completely translated. + + + +## Binders + +Validators represent a component of the Validation to be considered, but it does not represent other messages of its path. +To display messages from a given data-path, a Binder is needed. We bring a few to make this happen: + +UmbBindServerValidationToFormControl diff --git a/src/packages/core/validation/const.ts b/src/packages/core/validation/const.ts new file mode 100644 index 0000000000..0160e54bf4 --- /dev/null +++ b/src/packages/core/validation/const.ts @@ -0,0 +1 @@ +export const UMB_VALIDATION_EMPTY_LOCALIZATION_KEY = '#validation_invalidEmpty'; diff --git a/src/packages/core/validation/context/index.ts b/src/packages/core/validation/context/index.ts index 96fbced51a..99311cb3cf 100644 --- a/src/packages/core/validation/context/index.ts +++ b/src/packages/core/validation/context/index.ts @@ -1,4 +1,2 @@ export * from './validation.context.js'; export * from './validation.context-token.js'; -export * from './server-model-validation.context.js'; -export * from './server-model-validation.context-token.js'; diff --git a/src/packages/core/validation/context/server-model-validation.context-token.ts b/src/packages/core/validation/context/server-model-validation.context-token.ts deleted file mode 100644 index 1f8a1932dc..0000000000 --- a/src/packages/core/validation/context/server-model-validation.context-token.ts +++ /dev/null @@ -1,6 +0,0 @@ -import type { UmbServerModelValidationContext } from './index.js'; -import { UmbContextToken } from '@umbraco-cms/backoffice/context-api'; - -export const UMB_SERVER_MODEL_VALIDATION_CONTEXT = new UmbContextToken( - 'UmbServerModelValidationContext', -); diff --git a/src/packages/core/validation/context/server-model-validation.context.ts b/src/packages/core/validation/context/server-model-validation.context.ts deleted file mode 100644 index f969b3d4eb..0000000000 --- a/src/packages/core/validation/context/server-model-validation.context.ts +++ /dev/null @@ -1,144 +0,0 @@ -import type { UmbValidationMessageTranslator } from '../translators/validation-message-translator.interface.js'; -import type { UmbValidator } from '../interfaces/validator.interface.js'; -import { UMB_VALIDATION_CONTEXT } from './validation.context-token.js'; -import { UMB_SERVER_MODEL_VALIDATION_CONTEXT } from './server-model-validation.context-token.js'; -import { UmbContextBase } from '@umbraco-cms/backoffice/class-api'; -import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; -import type { UmbDataSourceResponse } from '@umbraco-cms/backoffice/repository'; - -type ServerFeedbackEntry = { path: string; messages: Array }; - -export class UmbServerModelValidationContext - extends UmbContextBase - implements UmbValidator -{ - #validatePromise?: Promise; - #validatePromiseResolve?: () => void; - - #context?: typeof UMB_VALIDATION_CONTEXT.TYPE; - #isValid = true; - - #data: any; - getData(): any { - return this.#data; - } - #translators: Array = []; - - // Hold server feedback... - #serverFeedback: Array = []; - - constructor(host: UmbControllerHost) { - super(host, UMB_SERVER_MODEL_VALIDATION_CONTEXT); - this.consumeContext(UMB_VALIDATION_CONTEXT, (context) => { - if (this.#context) { - this.#context.removeValidator(this); - } - this.#context = context; - context.addValidator(this); - - // Run translators? - }); - } - - async askServerForValidation(data: unknown, requestPromise: Promise>): Promise { - this.#context?.messages.removeMessagesByType('server'); - - this.#serverFeedback = []; - this.#isValid = false; - //this.#validatePromiseReject?.(); - this.#validatePromise = new Promise((resolve) => { - this.#validatePromiseResolve = resolve; - }); - - // Store this state of the data for translator look ups: - this.#data = data; - // Ask the server for validation... - const { error } = await requestPromise; - - this.#isValid = error ? false : true; - - if (!this.#isValid) { - // We are missing some typing here, but we will just go wild with 'as any': [NL] - const readErrorBody = (error as any).body; - // Check if there are validation errors, since the error might be a generic ApiError - if (readErrorBody?.errors) { - Object.keys(readErrorBody.errors).forEach((path) => { - this.#serverFeedback.push({ path, messages: readErrorBody.errors[path] }); - }); - } - } - - this.#validatePromiseResolve?.(); - this.#validatePromiseResolve = undefined; - - // Translate feedback: - this.#serverFeedback = this.#serverFeedback.flatMap(this.#executeTranslatorsOnFeedback); - } - - #executeTranslatorsOnFeedback = (feedback: ServerFeedbackEntry) => { - return this.#translators.flatMap((translator) => { - let newPath: string | undefined; - if ((newPath = translator.translate(feedback.path))) { - // TODO: I might need to restructure this part for adjusting existing feedback with a part-translation. - // Detect if some part is unhandled? - // If so only make a partial translation on the feedback, add a message for the handled part. - // then return [ of the partial translated feedback, and the partial handled part. ]; - - // TODO:Check if there was any temporary messages base on this path, like if it was partial-translated at one point.. - - this.#context?.messages.addMessages('server', newPath, feedback.messages); - // by not returning anything this feedback gets removed from server feedback.. - return []; - } - return feedback; - }); - }; - - addTranslator(translator: UmbValidationMessageTranslator): void { - if (this.#translators.indexOf(translator) === -1) { - this.#translators.push(translator); - } - // execute translators here? - } - - removeTranslator(translator: UmbValidationMessageTranslator): void { - const index = this.#translators.indexOf(translator); - if (index !== -1) { - this.#translators.splice(index, 1); - } - } - - get isValid(): boolean { - return this.#isValid; - } - async validate(): Promise { - if (this.#validatePromise) { - await this.#validatePromise; - } - return this.#isValid ? Promise.resolve() : Promise.reject(); - } - - reset(): void {} - - focusFirstInvalidElement(): void {} - - override hostConnected(): void { - super.hostConnected(); - if (this.#context) { - this.#context.addValidator(this); - } - } - override hostDisconnected(): void { - super.hostDisconnected(); - if (this.#context) { - this.#context.removeValidator(this); - this.#context = undefined; - } - } - - override destroy(): void { - // TODO: make sure we destroy things properly: - this.#translators = []; - super.destroy(); - } -} diff --git a/src/packages/core/validation/context/validation-messages.manager.ts b/src/packages/core/validation/context/validation-messages.manager.ts index 252f716e92..6104c31dc8 100644 --- a/src/packages/core/validation/context/validation-messages.manager.ts +++ b/src/packages/core/validation/context/validation-messages.manager.ts @@ -1,3 +1,4 @@ +import type { UmbValidationMessageTranslator } from '../translators/validation-message-path-translator.interface.js'; import type { Observable } from '@umbraco-cms/backoffice/external/rxjs'; import { UmbId } from '@umbraco-cms/backoffice/id'; import { UmbArrayState } from '@umbraco-cms/backoffice/observable-api'; @@ -7,35 +8,23 @@ export interface UmbValidationMessage { type: UmbValidationMessageType; key: string; path: string; - message: string; + body: string; } export class UmbValidationMessagesManager { #messages = new UmbArrayState([], (x) => x.key); + messages = this.#messages.asObservable(); /*constructor() { - this.#messages.asObservable().subscribe((x) => console.log('messages:', x)); + this.#messages.asObservable().subscribe((x) => console.log('all messages:', x)); }*/ - /* - serializeMessages(fromPath: string, toPath: string): void { - this.#messages.setValue( - this.#messages.getValue().map((x) => { - if (x.path.indexOf(fromPath) === 0) { - x.path = toPath + x.path.substring(fromPath.length); - } - return x; - }), - ); - } - */ - getHasAnyMessages(): boolean { return this.#messages.getValue().length !== 0; } - /* messagesOfPathAndDescendant(path: string): Observable> { + path = path.toLowerCase(); // Find messages that starts with the given path, if the path is longer then require a dot or [ as the next character. using a more performant way than Regex: return this.#messages.asObservablePart((msgs) => msgs.filter( @@ -45,16 +34,17 @@ export class UmbValidationMessagesManager { ), ); } - */ messagesOfTypeAndPath(type: UmbValidationMessageType, path: string): Observable> { + path = path.toLowerCase(); // Find messages that matches the given type and path. return this.#messages.asObservablePart((msgs) => msgs.filter((x) => x.type === type && x.path === path)); } hasMessagesOfPathAndDescendant(path: string): Observable { + path = path.toLowerCase(); return this.#messages.asObservablePart((msgs) => - // Find messages that starts with the given path, if the path is longer then require a dot or [ as the next character. Using a more performant way than Regex: + // Find messages that starts with the given path, if the path is longer then require a dot or [ as the next character. Using a more performant way than Regex: [NL] msgs.some( (x) => x.path.indexOf(path) === 0 && @@ -63,6 +53,7 @@ export class UmbValidationMessagesManager { ); } getHasMessagesOfPathAndDescendant(path: string): boolean { + path = path.toLowerCase(); return this.#messages .getValue() .some( @@ -72,33 +63,78 @@ export class UmbValidationMessagesManager { ); } - addMessage(type: UmbValidationMessageType, path: string, message: string): void { - this.#messages.appendOne({ type, key: UmbId.new(), path, message }); + addMessage(type: UmbValidationMessageType, path: string, body: string, key: string = UmbId.new()): void { + path = this.#translatePath(path.toLowerCase()) ?? path.toLowerCase(); + // check if there is an existing message with the same path and type, and append the new messages: [NL] + if (this.#messages.getValue().find((x) => x.type === type && x.path === path && x.body === body)) { + return; + } + this.#messages.appendOne({ type, key, path, body: body }); } - addMessages(type: UmbValidationMessageType, path: string, messages: Array): void { - this.#messages.append(messages.map((message) => ({ type, key: UmbId.new(), path, message }))); + addMessages(type: UmbValidationMessageType, path: string, bodies: Array): void { + path = this.#translatePath(path.toLowerCase()) ?? path.toLowerCase(); + // filter out existing messages with the same path and type, and append the new messages: [NL] + const existingMessages = this.#messages.getValue(); + const newBodies = bodies.filter( + (message) => existingMessages.find((x) => x.type === type && x.path === path && x.body === message) === undefined, + ); + this.#messages.append(newBodies.map((body) => ({ type, key: UmbId.new(), path, body }))); } - /* - removeMessage(message: UmbValidationDataPath): void { - this.#messages.removeOne(message.key); - }*/ removeMessageByKey(key: string): void { this.#messages.removeOne(key); } removeMessagesByTypeAndPath(type: UmbValidationMessageType, path: string): void { + path = path.toLowerCase(); this.#messages.filter((x) => !(x.type === type && x.path === path)); } removeMessagesByType(type: UmbValidationMessageType): void { this.#messages.filter((x) => x.type !== type); } - reset(): void { + #translatePath(path: string): string | undefined { + for (const translator of this.#translators) { + const newPath = translator.translate(path); + // If not undefined or false, then it was a valid translation: [NL] + if (newPath) { + // Lets try to translate it again, this will recursively translate the path until no more translations are possible (and then fallback to '?? newpath') [NL] + return this.#translatePath(newPath) ?? newPath; + } + } + return; + } + + #translators: Array = []; + addTranslator(translator: UmbValidationMessageTranslator): void { + if (this.#translators.indexOf(translator) === -1) { + this.#translators.push(translator); + } + // execute translators on all messages: + // Notice we are calling getValue() in each iteration to avoid the need to re-translate the same messages over and over again. [NL] + for (const msg of this.#messages.getValue()) { + const newPath = this.#translatePath(msg.path); + // If newPath is not false or undefined, a translation of it has occurred, meaning we ant to update it: [NL] + if (newPath) { + // update the specific message, with its new path: [NL] + this.#messages.updateOne(msg.key, { path: newPath }); + } + } + } + + removeTranslator(translator: UmbValidationMessageTranslator): void { + const index = this.#translators.indexOf(translator); + if (index !== -1) { + this.#translators.splice(index, 1); + } + } + + clear(): void { this.#messages.setValue([]); } destroy(): void { + this.#translators = []; this.#messages.destroy(); } } diff --git a/src/packages/core/validation/context/validation.context.ts b/src/packages/core/validation/context/validation.context.ts index 69ac384df9..627e7212d4 100644 --- a/src/packages/core/validation/context/validation.context.ts +++ b/src/packages/core/validation/context/validation.context.ts @@ -1,24 +1,201 @@ import type { UmbValidator } from '../interfaces/validator.interface.js'; -import { UmbValidationMessagesManager } from './validation-messages.manager.js'; +import type { UmbValidationMessageTranslator } from '../translators/index.js'; +import { GetValueByJsonPath } from '../utils/json-path.function.js'; +import { type UmbValidationMessage, UmbValidationMessagesManager } from './validation-messages.manager.js'; import { UMB_VALIDATION_CONTEXT } from './validation.context-token.js'; -import { UmbContextBase } from '@umbraco-cms/backoffice/class-api'; +import type { UmbContextProviderController } from '@umbraco-cms/backoffice/context-api'; +import { type UmbClassInterface, UmbControllerBase } from '@umbraco-cms/backoffice/class-api'; import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; +import { UmbObjectState } from '@umbraco-cms/backoffice/observable-api'; + +/** + * Helper method to replace the start of a string with another string. + * @param path {string} + * @param startFrom {string} + * @param startTo {string} + * @returns {string} + */ +function ReplaceStartOfString(path: string, startFrom: string, startTo: string): string { + if (path.startsWith(startFrom + '.')) { + return startTo + path.slice(startFrom.length); + } + return path; +} + +/** + * Validation Context is the core of Validation. + * It hosts Validators that has to validate for the context to be valid. + * It can also be used as a Validator as part of a parent Validation Context. + */ +export class UmbValidationContext extends UmbControllerBase implements UmbValidator { + // The current provider controller, that is providing this context: + #providerCtrl?: UmbContextProviderController; + + // Local version of the data send to the server, only use-case is for translation. + #translationData = new UmbObjectState(undefined); + translationDataOf(path: string): any { + return this.#translationData.asObservablePart((data) => GetValueByJsonPath(data, path)); + } + setTranslationData(data: any): void { + this.#translationData.setValue(data); + } + getTranslationData(): any { + return this.#translationData.getValue(); + } -export class UmbValidationContext extends UmbContextBase implements UmbValidator { #validators: Array = []; #validationMode: boolean = false; #isValid: boolean = false; + #parent?: UmbValidationContext; + #parentMessages?: Array; + #localMessages?: Array; + #baseDataPath?: string; + public readonly messages = new UmbValidationMessagesManager(); constructor(host: UmbControllerHost) { - super(host, UMB_VALIDATION_CONTEXT); + // This is overridden to avoid setting a controllerAlias, this might make sense, but currently i want to leave it out. [NL] + super(host); + } + + /** + * Add a path translator to this validation context. + * @param translator + */ + async addTranslator(translator: UmbValidationMessageTranslator) { + this.messages.addTranslator(translator); + } + + /** + * Remove a path translator from this validation context. + * @param translator + */ + async removeTranslator(translator: UmbValidationMessageTranslator) { + this.messages.removeTranslator(translator); + } + + /** + * Provides the validation context to the current host, if not already provided to a different host. + * @returns instance {UmbValidationContext} - Returns it self. + */ + provide(): UmbValidationContext { + if (this.#providerCtrl) return this; + this.provideContext(UMB_VALIDATION_CONTEXT, this); + return this; + } + /** + * Provide this validation context to a specific controller host. + * This can be used to Host a validation context in a Workspace, but provide it on a certain scope, like a specific Workspace View. + * @param controllerHost {UmbClassInterface} + */ + provideAt(controllerHost: UmbClassInterface): void { + this.#providerCtrl?.destroy(); + this.#providerCtrl = controllerHost.provideContext(UMB_VALIDATION_CONTEXT, this); + } + + /** + * 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. + * + * messages and data will be localizes accordingly to the given data path. + * @param dataPath {string} - The data path to bind this validation context to. + * @example + * ```ts + * const validationContext = new UmbValidationContext(host); + * validationContext.setDataPath("$.values[?(@.alias='my-property')].value"); + * ``` + * + * A message with the path: '$.values[?(@.alias='my-property')].value.innerProperty', will for above example become '$.innerProperty' for the local Validation Context. + */ + setDataPath(dataPath: string): void { + if (this.#baseDataPath) { + if (this.#baseDataPath === dataPath) return; + console.log(this.#baseDataPath, dataPath); + // Just fire an error, as I haven't made the right clean up jet. Or haven't thought about what should happen if it changes while already setup. + // cause maybe all the messages should be removed as we are not interested in the old once any more. But then on the other side, some might be relevant as this is the same entity that changed its paths? + throw new Error('Data path is already set, we do not support changing the context data-path as of now.'); + } + if (!dataPath) return; + this.#baseDataPath = dataPath; + + this.consumeContext(UMB_VALIDATION_CONTEXT, (parent) => { + if (this.#parent) { + this.#parent.removeValidator(this); + } + this.#parent = parent; + parent.addValidator(this); + + this.messages.clear(); + + this.observe(parent.translationDataOf(dataPath), (data) => { + this.setTranslationData(data); + }); + + this.observe( + parent.messages.messagesOfPathAndDescendant(dataPath), + (msgs) => { + //this.messages.appendMessages(msgs); + if (this.#parentMessages) { + // Remove the local messages that does not exist in the parent anymore: + const toRemove = this.#parentMessages.filter((msg) => !msgs.find((m) => m.key === msg.key)); + toRemove.forEach((msg) => { + this.messages.removeMessageByKey(msg.key); + }); + } + this.#parentMessages = msgs; + msgs.forEach((msg) => { + const path = ReplaceStartOfString(msg.path, this.#baseDataPath!, '$'); + // Notice, the local message uses the same key. [NL] + this.messages.addMessage(msg.type, path, msg.body, msg.key); + }); + }, + 'observeParentMessages', + ); + + this.observe( + this.messages.messages, + (msgs) => { + if (!this.#parent) return; + //this.messages.appendMessages(msgs); + 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)); + toRemove.forEach((msg) => { + this.#parent!.messages.removeMessageByKey(msg.key); + }); + } + this.#localMessages = msgs; + 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 = ReplaceStartOfString(msg.path, '$', this.#baseDataPath!); + // Notice, the parent message uses the same key. [NL] + this.#parent!.messages.addMessage(msg.type, path, msg.body, msg.key); + }); + }, + 'observeLocalMessages', + ); + }).skipHost(); + // Notice skipHost ^^, this is because we do not want it to consume it self, as this would be a match for this consumption, instead we will look at the parent and above. [NL] } + /** + * Get if this context is valid. + * Notice this does not verify the validity. + * @returns {boolean} + */ get isValid(): boolean { return this.#isValid; } + /** + * Add a validator to this context. + * This validator will have to be valid for the context to be valid. + * If the context is in validation mode, the validator will be validated immediately. + * @param validator { UmbValidator } - The validator to add to this context. + */ addValidator(validator: UmbValidator): void { if (this.#validators.includes(validator)) return; this.#validators.push(validator); @@ -27,6 +204,11 @@ export class UmbValidationContext extends UmbContextBase i this.validate(); } } + + /** + * Remove a validator from this context. + * @param validator {UmbValidator} - The validator to remove from this context. + */ removeValidator(validator: UmbValidator): void { const index = this.#validators.indexOf(validator); if (index !== -1) { @@ -39,27 +221,9 @@ export class UmbValidationContext extends UmbContextBase i } } - /*#onValidatorChange = (e: Event) => { - const target = e.target as unknown as UmbValidator | undefined; - if (!target) { - console.error('Validator did not exist.'); - return; - } - const dataPath = target.dataPath; - if (!dataPath) { - console.error('Validator did not exist or did not provide a data-path.'); - return; - } - - if (target.isValid) { - this.messages.removeMessagesByTypeAndPath('client', dataPath); - } else { - this.messages.addMessages('client', dataPath, target.getMessages()); - } - };*/ - /** - * + * Validate this context, all the validators of this context will be validated. + * Notice its a recursive check meaning sub validation contexts also validates their validators. * @returns succeed {Promise} - Returns a promise that resolves to true if the validator succeeded, this depends on the validators and wether forceSucceed is set. */ async validate(): Promise { @@ -71,6 +235,11 @@ export class UmbValidationContext extends UmbContextBase i () => Promise.resolve(false), ); + if (!this.messages) { + // This Context has been destroyed while is was validating, so we should not continue. + return; + } + // If we have any messages then we are not valid, otherwise lets check the validation results: [NL] // This enables us to keep client validations though UI is not present anymore — because the client validations got defined as messages. [NL] const isValid = this.messages.getHasAnyMessages() ? false : resultsStatus; @@ -86,6 +255,9 @@ export class UmbValidationContext extends UmbContextBase i return Promise.resolve(); } + /** + * Focus the first invalid element that this context can find. + */ focusFirstInvalidElement(): void { const firstInvalid = this.#validators.find((v) => !v.isValid); if (firstInvalid) { @@ -93,6 +265,9 @@ export class UmbValidationContext extends UmbContextBase i } } + /** + * Reset the validation state of this context. + */ reset(): void { this.#validationMode = false; this.#validators.forEach((v) => v.reset()); @@ -108,7 +283,14 @@ export class UmbValidationContext extends UmbContextBase i } override destroy(): void { + this.#providerCtrl = undefined; + if (this.#parent) { + this.#parent.removeValidator(this); + } + this.#parent = undefined; this.#destroyValidators(); + this.messages?.destroy(); + (this.messages as any) = undefined; super.destroy(); } } diff --git a/src/packages/core/validation/controllers/bind-validation-message-to-form-control.controller.ts b/src/packages/core/validation/controllers/bind-server-validation-to-form-control.controller.ts similarity index 87% rename from src/packages/core/validation/controllers/bind-validation-message-to-form-control.controller.ts rename to src/packages/core/validation/controllers/bind-server-validation-to-form-control.controller.ts index 2bfc4e49c2..9b2857a841 100644 --- a/src/packages/core/validation/controllers/bind-validation-message-to-form-control.controller.ts +++ b/src/packages/core/validation/controllers/bind-server-validation-to-form-control.controller.ts @@ -1,14 +1,18 @@ import type { UmbValidationMessage } from '../context/validation-messages.manager.js'; import { UMB_VALIDATION_CONTEXT } from '../context/validation.context-token.js'; import type { UmbFormControlMixinInterface } from '../mixins/form-control.mixin.js'; -import { jsonStringComparison } from '@umbraco-cms/backoffice/observable-api'; +import { defaultMemoization } from '@umbraco-cms/backoffice/observable-api'; import { UmbControllerBase } from '@umbraco-cms/backoffice/class-api'; import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; const ctrlSymbol = Symbol(); const observeSymbol = Symbol(); -export class UmbBindValidationMessageToFormControl extends UmbControllerBase { +/** + * Binds server validation to a form control. + * This controller will add a custom error to the form control if the validation context has any messages for the specified data path. + */ +export class UmbBindServerValidationToFormControl extends UmbControllerBase { #context?: typeof UMB_VALIDATION_CONTEXT.TYPE; #control: UmbFormControlMixinInterface; @@ -24,7 +28,7 @@ export class UmbBindValidationMessageToFormControl extends UmbControllerBase { this.#value = value; } else { // If not valid lets see if we should remove server validation [NL] - if (!jsonStringComparison(this.#value, value)) { + if (!defaultMemoization(this.#value, value)) { this.#value = value; // Only remove server validations from validation context [NL] this.#messages.forEach((message) => { @@ -62,7 +66,7 @@ export class UmbBindValidationMessageToFormControl extends UmbControllerBase { if (!this.#controlValidator) { this.#controlValidator = this.#control.addValidator( 'customError', - () => this.#messages.map((x) => x.message).join(', '), + () => this.#messages.map((x) => x.body).join(', '), () => !this.#isValid, ); //this.#control.addEventListener('change', this.#onControlChange); diff --git a/src/packages/core/validation/controllers/form-control-validator.controller.ts b/src/packages/core/validation/controllers/form-control-validator.controller.ts index 244025e09d..743b94bc41 100644 --- a/src/packages/core/validation/controllers/form-control-validator.controller.ts +++ b/src/packages/core/validation/controllers/form-control-validator.controller.ts @@ -6,6 +6,10 @@ import { UmbValidationValidEvent } from '../events/validation-valid.event.js'; import { UmbControllerBase } from '@umbraco-cms/backoffice/class-api'; import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; +/** + * Bind a Form Controls validation state to the validation context. + * This validator will validate the form control and add messages to the validation context if the form control is invalid. + */ export class UmbFormControlValidator extends UmbControllerBase implements UmbValidator { // The path to the data that this validator is validating. readonly #dataPath?: string; diff --git a/src/packages/core/validation/controllers/index.ts b/src/packages/core/validation/controllers/index.ts index e261d92414..66e51504c5 100644 --- a/src/packages/core/validation/controllers/index.ts +++ b/src/packages/core/validation/controllers/index.ts @@ -1,3 +1,5 @@ -export * from './bind-validation-message-to-form-control.controller.js'; -export * from './observe-validation-state.controller.js'; +export * from './bind-server-validation-to-form-control.controller.js'; export * from './form-control-validator.controller.js'; +export * from './observe-validation-state.controller.js'; +export * from './server-model-validator.context-token.js'; +export * from './server-model-validator.context.js'; diff --git a/src/packages/core/validation/controllers/observe-validation-state.controller.ts b/src/packages/core/validation/controllers/observe-validation-state.controller.ts index 3c4ee10667..798f63d6c0 100644 --- a/src/packages/core/validation/controllers/observe-validation-state.controller.ts +++ b/src/packages/core/validation/controllers/observe-validation-state.controller.ts @@ -2,12 +2,16 @@ import { UMB_VALIDATION_CONTEXT } from '../context/validation.context-token.js'; import { UmbControllerBase } from '@umbraco-cms/backoffice/class-api'; import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; -const CtrlSymbol = Symbol(); const ObserveSymbol = Symbol(); export class UmbObserveValidationStateController extends UmbControllerBase { - constructor(host: UmbControllerHost, dataPath: string | undefined, callback: (messages: boolean) => void) { - super(host, CtrlSymbol); + constructor( + host: UmbControllerHost, + dataPath: string | undefined, + callback: (messages: boolean) => void, + controllerAlias?: string, + ) { + super(host, controllerAlias ?? 'observeValidationState_' + dataPath); if (dataPath) { this.consumeContext(UMB_VALIDATION_CONTEXT, (context) => { this.observe(context.messages.hasMessagesOfPathAndDescendant(dataPath), callback, ObserveSymbol); diff --git a/src/packages/core/validation/controllers/server-model-validator.context-token.ts b/src/packages/core/validation/controllers/server-model-validator.context-token.ts new file mode 100644 index 0000000000..67f3cdbffa --- /dev/null +++ b/src/packages/core/validation/controllers/server-model-validator.context-token.ts @@ -0,0 +1,6 @@ +import type { UmbServerModelValidatorContext } from './server-model-validator.context.js'; +import { UmbContextToken } from '@umbraco-cms/backoffice/context-api'; + +export const UMB_SERVER_MODEL_VALIDATOR_CONTEXT = new UmbContextToken( + 'UmbServerModelValidationContext', +); diff --git a/src/packages/core/validation/controllers/server-model-validator.context.ts b/src/packages/core/validation/controllers/server-model-validator.context.ts new file mode 100644 index 0000000000..bee1c2369c --- /dev/null +++ b/src/packages/core/validation/controllers/server-model-validator.context.ts @@ -0,0 +1,137 @@ +import type { UmbValidator } from '../interfaces/validator.interface.js'; +import { UmbDataPathPropertyValueQuery } from '../utils/index.js'; +import { UMB_VALIDATION_CONTEXT } from '../context/validation.context-token.js'; +import { UMB_VALIDATION_EMPTY_LOCALIZATION_KEY } from '../const.js'; +import { UMB_SERVER_MODEL_VALIDATOR_CONTEXT } from './server-model-validator.context-token.js'; +import { UmbContextBase } from '@umbraco-cms/backoffice/class-api'; +import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; +import type { UmbDataSourceResponse } from '@umbraco-cms/backoffice/repository'; + +/** This should ideally be generated by the server, but we currently don't generate error-model-types. */ +interface ValidateErrorResponseBodyModel { + detail: string; + errors: Record>; + missingProperties: Array; + operationStatus: string; + status: number; + title: string; + type: string; +} + +export class UmbServerModelValidatorContext + extends UmbContextBase + implements UmbValidator +{ + #validatePromise?: Promise; + #validatePromiseResolve?: () => void; + + #context?: typeof UMB_VALIDATION_CONTEXT.TYPE; + #isValid = true; + + #data: any; + getData(): any { + return this.#data; + } + + constructor(host: UmbControllerHost) { + super(host, UMB_SERVER_MODEL_VALIDATOR_CONTEXT); + this.consumeContext(UMB_VALIDATION_CONTEXT, (context) => { + if (this.#context) { + this.#context.removeValidator(this); + } + this.#context = context; + context.addValidator(this); + + // Run translators? + }).asPromise(); + } + + async askServerForValidation(data: unknown, requestPromise: Promise>): Promise { + this.#context?.messages.removeMessagesByType('server'); + + this.#isValid = false; + //this.#validatePromiseReject?.(); + this.#validatePromise = new Promise((resolve) => { + this.#validatePromiseResolve = resolve; + }); + + // Store this state of the data for translator look ups: + this.#data = data; + // Ask the server for validation... + const { error } = await requestPromise; + + this.#isValid = error ? false : true; + if (this.#isValid) { + // Send data to context for translation: + this.#context?.setTranslationData(undefined); + } else { + if (!this.#context) { + throw new Error('No context available for translation.'); + } + // Send data to context for translation: + this.#context.setTranslationData(data); + + // We are missing some typing here, but we will just go wild with 'as any': [NL] + const errorBody = (error as any).body as ValidateErrorResponseBodyModel; + // Check if there are validation errors, since the error might be a generic ApiError + if (errorBody?.errors) { + Object.keys(errorBody.errors).forEach((path) => { + //serverFeedback.push({ path, messages: errorBody.errors[path] }); + this.#context!.messages.addMessages('server', path, errorBody.errors[path]); + }); + } + // Check if there are missing properties: + if (errorBody?.missingProperties) { + // Retrieve the variants of he send data, as those are the once we will declare as missing properties: + // Temporary fix for missing properties, as we currently get one for each variant, but we do not know which variant it is for: [NL] + const uniqueMissingProperties = [...new Set(errorBody.missingProperties)]; + uniqueMissingProperties.forEach((alias) => { + this.#data.variants.forEach((variant: any) => { + const path = `$.values[${UmbDataPathPropertyValueQuery({ + alias: alias, + culture: variant.culture, + segment: variant.segment, + })}].value`; + this.#context!.messages.addMessages('server', path, [UMB_VALIDATION_EMPTY_LOCALIZATION_KEY]); + }); + }); + } + } + + this.#validatePromiseResolve?.(); + this.#validatePromiseResolve = undefined; + } + + get isValid(): boolean { + return this.#isValid; + } + async validate(): Promise { + if (this.#validatePromise) { + await this.#validatePromise; + } + return this.#isValid ? Promise.resolve() : Promise.reject(); + } + + reset(): void {} + + focusFirstInvalidElement(): void {} + + override hostConnected(): void { + super.hostConnected(); + if (this.#context) { + this.#context.addValidator(this); + } + } + override hostDisconnected(): void { + super.hostDisconnected(); + if (this.#context) { + this.#context.removeValidator(this); + this.#context = undefined; + } + } + + override destroy(): void { + // TODO: make sure we destroy things properly: + super.destroy(); + } +} diff --git a/src/packages/core/validation/directives/bind-to-validation.lit-directive.ts b/src/packages/core/validation/directives/bind-to-validation.lit-directive.ts new file mode 100644 index 0000000000..a23e79e1bb --- /dev/null +++ b/src/packages/core/validation/directives/bind-to-validation.lit-directive.ts @@ -0,0 +1,84 @@ +import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; +import { AsyncDirective, directive, nothing, type ElementPart } from '@umbraco-cms/backoffice/external/lit'; +import type { UmbFormControlMixinInterface } from '@umbraco-cms/backoffice/validation'; +import { UmbBindServerValidationToFormControl, UmbFormControlValidator } from '@umbraco-cms/backoffice/validation'; + +/** + * The `bind to validation` directive connects the Form Control Element to closets Validation Context. + */ +class UmbBindToValidationDirective extends AsyncDirective { + #host?: UmbControllerHost; + #dataPath?: string; + #el?: UmbFormControlMixinInterface; + #validator?: UmbFormControlValidator; + #msgBinder?: UmbBindServerValidationToFormControl; + + // For Directives their arguments have to be defined on the Render method, despite them, not being used by the render method. In this case they are used by the update method. + // eslint-disable-next-line @typescript-eslint/no-unused-vars + override render(host: UmbControllerHost, dataPath?: string, value?: unknown) { + return nothing; + } + + override update(part: ElementPart, args: Parameters) { + if (!part.element) return nothing; + if (this.#el !== part.element || this.#host !== args[0] || this.#dataPath !== args[1]) { + this.#host = args[0]; + this.#dataPath = args[1]; + this.#el = part.element as UmbFormControlMixinInterface; + + if (!this.#msgBinder && this.#dataPath) { + this.#msgBinder = new UmbBindServerValidationToFormControl(this.#host, this.#el as any, this.#dataPath); + } + + this.#validator = new UmbFormControlValidator(this.#host, this.#el, this.#dataPath); + } + + // If we have a msgBinder, then lets update the value no matter the other conditions. + if (this.#msgBinder) { + this.#msgBinder.value = args[2]; + } + + return nothing; + } + + override disconnected() { + if (this.#validator) { + this.#validator?.destroy(); + this.#validator = undefined; + } + if (this.#msgBinder) { + this.#msgBinder.destroy(); + this.#msgBinder = undefined; + } + this.#el = undefined; + } + + /*override reconnected() { + }*/ +} + +/** + * @description + * A Lit directive, which binds the validation state of the element to the Validation Context. + * + * The minimal binding can be established by just providing a host element: + * @example: + * ```js + * html``; + * ``` + * But can be extended with a dataPath, which is the path to the data holding the value. (if no server validation in this context, then this can be fictive and is then just used internal for remembering the validation state despite the element begin removed from the DOM.) + * @example: + * ```js + * html``; + * ``` + * + * Additional the value can be provided, which is then used to remove a server validation state, if the value is changed. + * @example: + * ```js + * html``; + * ``` + * + */ +export const umbBindToValidation = directive(UmbBindToValidationDirective); + +//export type { UmbFocusDirective }; diff --git a/src/packages/core/validation/index.ts b/src/packages/core/validation/index.ts index 9e9b667fa8..ecb51f74ac 100644 --- a/src/packages/core/validation/index.ts +++ b/src/packages/core/validation/index.ts @@ -1,3 +1,4 @@ +export * from './const.js'; export * from './context/index.js'; export * from './controllers/index.js'; export * from './events/index.js'; @@ -5,3 +6,4 @@ export * from './interfaces/index.js'; export * from './mixins/index.js'; export * from './translators/index.js'; export * from './utils/index.js'; +export * from './directives/bind-to-validation.lit-directive.js'; diff --git a/src/packages/core/validation/mixins/form-control.mixin.ts b/src/packages/core/validation/mixins/form-control.mixin.ts index b390b64463..4a634d197c 100644 --- a/src/packages/core/validation/mixins/form-control.mixin.ts +++ b/src/packages/core/validation/mixins/form-control.mixin.ts @@ -13,8 +13,8 @@ type UmbNativeFormControlElement = Pick< * https://developer.mozilla.org/en-US/docs/Web/API/ValidityState * */ type FlagTypes = - | 'badInput' | 'customError' + | 'badInput' | 'patternMismatch' | 'rangeOverflow' | 'rangeUnderflow' @@ -23,14 +23,27 @@ type FlagTypes = | 'tooShort' | 'typeMismatch' | 'valueMissing' - | 'badInput' | 'valid'; +const WeightedErrorFlagTypes = [ + 'customError', + 'valueMissing', + 'badInput', + 'typeMismatch', + 'patternMismatch', + 'rangeOverflow', + 'rangeUnderflow', + 'stepMismatch', + 'tooLong', + 'tooShort', +]; + // Acceptable as an internal interface/type, BUT if exposed externally this should be turned into a public interface in a separate file. export interface UmbFormControlValidatorConfig { flagKey: FlagTypes; getMessageMethod: () => string; checkMethod: () => boolean; + weight: number; } export interface UmbFormControlMixinInterface extends HTMLElement { @@ -216,8 +229,11 @@ export function UmbFormControlMixin< flagKey: flagKey, getMessageMethod: getMessageMethod, checkMethod: checkMethod, + weight: WeightedErrorFlagTypes.indexOf(flagKey), } satisfies UmbFormControlValidatorConfig; this.#validators.push(validator); + // Sort validators based on the WeightedErrorFlagTypes order. [NL] + this.#validators.sort((a, b) => (a.weight > b.weight ? 1 : b.weight > a.weight ? -1 : 0)); return validator; } @@ -287,29 +303,38 @@ export function UmbFormControlMixin< */ protected _runValidators() { this.#validity = {}; - const messages: Set = new Set(); + //const messages: Set = new Set(); + let message: string | undefined = undefined; let innerFormControlEl: UmbNativeFormControlElement | undefined = undefined; - // Loop through inner native form controls to adapt their validityState. [NL] - this.#formCtrlElements.forEach((formCtrlEl) => { - let key: keyof ValidityState; - for (key in formCtrlEl.validity) { - if (key !== 'valid' && formCtrlEl.validity[key]) { - this.#validity[key] = true; - messages.add(formCtrlEl.validationMessage); - innerFormControlEl ??= formCtrlEl; - } - } - }); - // Loop through custom validators, currently its intentional to have them overwritten native validity. but might need to be reconsidered (This current way enables to overwrite with custom messages) [NL] - this.#validators.forEach((validator) => { + this.#validators.some((validator) => { if (validator.checkMethod()) { this.#validity[validator.flagKey] = true; - messages.add(validator.getMessageMethod()); + //messages.add(validator.getMessageMethod()); + message = validator.getMessageMethod(); + return true; } + return false; }); + if (!message) { + // Loop through inner native form controls to adapt their validityState. [NL] + this.#formCtrlElements.some((formCtrlEl) => { + let key: keyof ValidityState; + for (key in formCtrlEl.validity) { + if (key !== 'valid' && formCtrlEl.validity[key]) { + this.#validity[key] = true; + //messages.add(formCtrlEl.validationMessage); + message = formCtrlEl.validationMessage; + innerFormControlEl ??= formCtrlEl; + return true; + } + } + return false; + }); + } + const hasError = Object.values(this.#validity).includes(true); // https://developer.mozilla.org/en-US/docs/Web/API/ValidityState#valid @@ -319,7 +344,8 @@ export function UmbFormControlMixin< this._internals.setValidity( this.#validity, // Turn messages into an array and join them with a comma. [NL]: - [...messages].join(', '), + //[...messages].join(', '), + message, innerFormControlEl ?? this.getFormElement() ?? undefined, ); diff --git a/src/packages/core/validation/translators/abstract-array-path-translator.controller.ts b/src/packages/core/validation/translators/abstract-array-path-translator.controller.ts new file mode 100644 index 0000000000..121310af55 --- /dev/null +++ b/src/packages/core/validation/translators/abstract-array-path-translator.controller.ts @@ -0,0 +1,42 @@ +import { UmbValidationPathTranslatorBase } from './validation-path-translator-base.controller.js'; +import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; + +export abstract class UmbAbstractArrayValidationPathTranslator extends UmbValidationPathTranslatorBase { + #initialPathToMatch: string; + #queryMethod: (data: unknown) => string; + + constructor(host: UmbControllerHost, initialPathToMatch: string, queryMethod: (data: any) => string) { + super(host); + + this.#initialPathToMatch = initialPathToMatch; + this.#queryMethod = queryMethod; + } + translate(path: string) { + if (!this._context) return; + if (path.indexOf(this.#initialPathToMatch) !== 0) { + // We do not handle this path. + return false; + } + const pathEnd = path.indexOf(']'); + if (pathEnd === -1) { + // We do not handle this path. + return false; + } + // retrieve the number from the message values index: [NL] + const index = parseInt(path.substring(this.#initialPathToMatch.length, pathEnd)); + + if (isNaN(index)) { + // index is not a number, this means its not a path we want to translate. [NL] + return false; + } + + // Get the data from the validation request, the context holds that for us: [NL] + const data = this.getDataFromIndex(index); + + if (!data) return false; + // replace the values[ number ] with JSON-Path filter values[@.(...)], continues by the rest of the path: + return this.#initialPathToMatch + this.#queryMethod(data) + path.substring(path.indexOf(']')); + } + + abstract getDataFromIndex(index: number): unknown | undefined; +} diff --git a/src/packages/core/validation/translators/index.ts b/src/packages/core/validation/translators/index.ts index 43b09bb49f..9192c2b141 100644 --- a/src/packages/core/validation/translators/index.ts +++ b/src/packages/core/validation/translators/index.ts @@ -1,2 +1,5 @@ -export type * from './validation-message-translator.interface.js'; -export * from './variant-values-validation-message-translator.controller.js'; +export * from './validation-path-translator-base.controller.js'; +export * from './abstract-array-path-translator.controller.js'; +export * from './variant-values-validation-path-translator.controller.js'; +export * from './variants-validation-path-translator.controller.js'; +export type * from './validation-message-path-translator.interface.js'; diff --git a/src/packages/core/validation/translators/validation-message-path-translator.interface.ts b/src/packages/core/validation/translators/validation-message-path-translator.interface.ts new file mode 100644 index 0000000000..9d7509e327 --- /dev/null +++ b/src/packages/core/validation/translators/validation-message-path-translator.interface.ts @@ -0,0 +1,8 @@ +export interface UmbValidationMessageTranslator { + /** + * + * @param path - The path to translate + * @returns {false | undefined | string} - Returns false if the path is not handled by this translator, undefined if the path is invalid, or the translated path as a string. + */ + translate(path: string): false | undefined | string; +} diff --git a/src/packages/core/validation/translators/validation-message-translator.interface.ts b/src/packages/core/validation/translators/validation-message-translator.interface.ts deleted file mode 100644 index 80b66fa608..0000000000 --- a/src/packages/core/validation/translators/validation-message-translator.interface.ts +++ /dev/null @@ -1,3 +0,0 @@ -export interface UmbValidationMessageTranslator { - translate(message: string): undefined | string; -} diff --git a/src/packages/core/validation/translators/validation-path-translator-base.controller.ts b/src/packages/core/validation/translators/validation-path-translator-base.controller.ts new file mode 100644 index 0000000000..27dda12276 --- /dev/null +++ b/src/packages/core/validation/translators/validation-path-translator-base.controller.ts @@ -0,0 +1,30 @@ +import { UMB_VALIDATION_CONTEXT } from '../index.js'; +import type { UmbValidationMessageTranslator } from './validation-message-path-translator.interface.js'; +import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; +import { UmbControllerBase } from '@umbraco-cms/backoffice/class-api'; + +export abstract class UmbValidationPathTranslatorBase + extends UmbControllerBase + implements UmbValidationMessageTranslator +{ + // + protected _context?: typeof UMB_VALIDATION_CONTEXT.TYPE; + + constructor(host: UmbControllerHost) { + super(host); + + this.consumeContext(UMB_VALIDATION_CONTEXT, (context) => { + this._context?.removeTranslator(this); + this._context = context; + context.addTranslator(this); + }); + } + + override hostDisconnected(): void { + this._context?.removeTranslator(this); + this._context = undefined; + super.hostDisconnected(); + } + + abstract translate(path: string): ReturnType; +} diff --git a/src/packages/core/validation/translators/variant-values-validation-message-translator.controller.ts b/src/packages/core/validation/translators/variant-values-validation-message-translator.controller.ts deleted file mode 100644 index 7b10823bb4..0000000000 --- a/src/packages/core/validation/translators/variant-values-validation-message-translator.controller.ts +++ /dev/null @@ -1,49 +0,0 @@ -import type { UmbServerModelValidationContext } from '../context/server-model-validation.context.js'; -import { UmbDataPathPropertyValueFilter } from '../utils/data-path-property-value-filter.function.js'; -import type { UmbValidationMessageTranslator } from './validation-message-translator.interface.js'; -import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; -import { UmbControllerBase } from '@umbraco-cms/backoffice/class-api'; - -export class UmbVariantValuesValidationMessageTranslator - extends UmbControllerBase - implements UmbValidationMessageTranslator -{ - // - #context: UmbServerModelValidationContext; - - constructor(host: UmbControllerHost, context: UmbServerModelValidationContext) { - super(host); - context.addTranslator(this); - this.#context = context; - } - - translate(path: string) { - if (path.indexOf('$.values[') !== 0) { - // No translation anyway. - return; - } - const pathEnd = path.indexOf(']'); - if (pathEnd === -1) { - // No translation anyway. - return; - } - // retrieve the number from the message values index: [NL] - const index = parseInt(path.substring(9, pathEnd)); - - if (isNaN(index)) { - // No translation anyway. - return; - } - // Get the data from the validation request, the context holds that for us: [NL] - const data = this.#context.getData(); - - const specificValue = data.values[index]; - // replace the values[ number ] with JSON-Path filter values[@.(...)], continues by the rest of the path: - return '$.values[' + UmbDataPathPropertyValueFilter(specificValue) + path.substring(path.indexOf(']')); - } - - override destroy(): void { - super.destroy(); - this.#context.removeTranslator(this); - } -} diff --git a/src/packages/core/validation/translators/variant-values-validation-path-translator.controller.ts b/src/packages/core/validation/translators/variant-values-validation-path-translator.controller.ts new file mode 100644 index 0000000000..f3e1020607 --- /dev/null +++ b/src/packages/core/validation/translators/variant-values-validation-path-translator.controller.ts @@ -0,0 +1,15 @@ +import { UmbDataPathPropertyValueQuery } from '../utils/data-path-property-value-query.function.js'; +import { UmbAbstractArrayValidationPathTranslator } from './abstract-array-path-translator.controller.js'; +import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; + +export class UmbVariantValuesValidationPathTranslator extends UmbAbstractArrayValidationPathTranslator { + constructor(host: UmbControllerHost) { + super(host, '$.values[', UmbDataPathPropertyValueQuery); + } + + getDataFromIndex(index: number) { + if (!this._context) return; + const data = this._context.getTranslationData(); + return data.values[index]; + } +} diff --git a/src/packages/core/validation/translators/variants-validation-path-translator.controller.ts b/src/packages/core/validation/translators/variants-validation-path-translator.controller.ts new file mode 100644 index 0000000000..545e2a5488 --- /dev/null +++ b/src/packages/core/validation/translators/variants-validation-path-translator.controller.ts @@ -0,0 +1,15 @@ +import { UmbDataPathVariantQuery } from '../utils/data-path-variant-query.function.js'; +import { UmbAbstractArrayValidationPathTranslator } from './abstract-array-path-translator.controller.js'; +import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; + +export class UmbVariantsValidationPathTranslator extends UmbAbstractArrayValidationPathTranslator { + constructor(host: UmbControllerHost) { + super(host, '$.variants[', UmbDataPathVariantQuery); + } + + getDataFromIndex(index: number) { + if (!this._context) return; + const data = this._context.getTranslationData(); + return data.variants[index]; + } +} diff --git a/src/packages/core/validation/utils/data-path-property-value-filter.function.ts b/src/packages/core/validation/utils/data-path-property-value-query.function.ts similarity index 66% rename from src/packages/core/validation/utils/data-path-property-value-filter.function.ts rename to src/packages/core/validation/utils/data-path-property-value-query.function.ts index 408cf91b06..d184f6e205 100644 --- a/src/packages/core/validation/utils/data-path-property-value-filter.function.ts +++ b/src/packages/core/validation/utils/data-path-property-value-query.function.ts @@ -2,22 +2,22 @@ import type { UmbPartialSome } from '@umbraco-cms/backoffice/utils'; import type { UmbVariantPropertyValueModel } from '@umbraco-cms/backoffice/variant'; /** - * Validation Data Path filter for Property Value. + * Validation Data Path Query generator for Property Value. * write a JSON-Path filter similar to `?(@.alias = 'myAlias' && @.culture == 'en-us' && @.segment == 'mySegment')` * where culture and segment are optional * @param value * @returns */ -export function UmbDataPathPropertyValueFilter( +export function UmbDataPathPropertyValueQuery( value: UmbPartialSome, 'culture' | 'segment'>, ): string { // write a array of strings for each property, where alias must be present and culture and segment are optional const filters: Array = [`@.alias = '${value.alias}'`]; - if (value.culture) { - filters.push(`@.culture == '${value.culture}'`); + if (value.culture !== undefined) { + filters.push(`@.culture = ${value.culture ? `'${value.culture}'` : 'null'}`); } - if (value.segment) { - filters.push(`@.segment == '${value.segment}'`); + if (value.segment !== undefined) { + filters.push(`@.segment = ${value.segment ? `'${value.segment}'` : 'null'}`); } return `?(${filters.join(' && ')})`; } diff --git a/src/packages/core/validation/utils/data-path-variant-query.function.ts b/src/packages/core/validation/utils/data-path-variant-query.function.ts new file mode 100644 index 0000000000..25666269cd --- /dev/null +++ b/src/packages/core/validation/utils/data-path-variant-query.function.ts @@ -0,0 +1,20 @@ +import type { UmbPartialSome } from '@umbraco-cms/backoffice/utils'; +import type { UmbVariantPropertyValueModel } from '@umbraco-cms/backoffice/variant'; + +/** + * Validation Data Path query generator for Variant. + * write a JSON-Path filter similar to `?(@.culture == 'en-us' && @.segment == 'mySegment')` + * where segment are optional. + * @param value + * @returns + */ +export function UmbDataPathVariantQuery( + value: UmbPartialSome, 'segment'>, +): string { + // write a array of strings for each property, where culture must be present and segment is optional + const filters: Array = [`@.culture = ${value.culture ? `'${value.culture}'` : 'null'}`]; + if (value.segment !== undefined) { + filters.push(`@.segment = ${value.segment ? `'${value.segment}'` : 'null'}`); + } + return `?(${filters.join(' && ')})`; +} diff --git a/src/packages/core/validation/utils/index.ts b/src/packages/core/validation/utils/index.ts index 1fb8cf117c..52e7d2c3c4 100644 --- a/src/packages/core/validation/utils/index.ts +++ b/src/packages/core/validation/utils/index.ts @@ -1 +1,3 @@ -export * from './data-path-property-value-filter.function.js'; +export * from './data-path-property-value-query.function.js'; +export * from './data-path-variant-query.function.js'; +export * from './json-path.function.js'; diff --git a/src/packages/core/validation/utils/json-path.function.ts b/src/packages/core/validation/utils/json-path.function.ts new file mode 100644 index 0000000000..98606e2ee3 --- /dev/null +++ b/src/packages/core/validation/utils/json-path.function.ts @@ -0,0 +1,112 @@ +/** + * + * @param data + * @param path + */ +export function GetValueByJsonPath(data: any, path: string): any { + // strip $ from the path: + const strippedPath = path.startsWith('$.') ? path.slice(2) : path; + // get value from the path: + return GetNextPropertyValueFromPath(data, strippedPath); +} + +/** + * + * @param path + */ +export function GetPropertyNameFromPath(path: string): string { + // find next '.' or '[' in the path, using regex: + const match = path.match(/\.|\[/); + // If no match is found, we assume its a single key so lets return the value of the key: + if (match === null || match.index === undefined) return path; + + // split the path at the first match: + return path.slice(0, match.index); +} + +/** + * + * @param data + * @param path + */ +function GetNextPropertyValueFromPath(data: any, path: string): any { + if (!data) return undefined; + // find next '.' or '[' in the path, using regex: + const match = path.match(/\.|\[/); + // If no match is found, we assume its a single key so lets return the value of the key: + if (match === null || match.index === undefined) return data[path]; + + // split the path at the first match: + const key = path.slice(0, match.index); + const rest = path.slice(match.index + 1); + + if (!key) return undefined; + // get the value of the key from the data: + const value = data[key]; + // if there is no rest of the path, return the value: + if (rest === undefined) return value; + // if the value is an array, get the value at the index: + if (Array.isArray(value)) { + // get the value until the next ']', the value can be anything in between the brackets: + const lookupEnd = rest.match(/\]/); + if (!lookupEnd) return undefined; + // get everything before the match: + const entryPointer = rest.slice(0, lookupEnd.index); + + // check if the entryPointer is a JSON Path Filter ( starting with ?( and ending with ) ): + if (entryPointer.startsWith('?(') && entryPointer.endsWith(')')) { + // get the filter from the entryPointer: + console.log('query', entryPointer); + // get the filter as a function: + const jsFilter = JsFilterFromJsonPathFilter(entryPointer); + // find the index of the value that matches the filter: + const index = value.findIndex(jsFilter[0]); + // if the index is -1, return undefined: + if (index === -1) return undefined; + // get the value at the index: + const data = value[index]; + // Check for safety: + if (lookupEnd.index === undefined || lookupEnd.index + 1 >= rest.length) { + return data; + } + // continue with the rest of the path: + return GetNextPropertyValueFromPath(data, rest.slice(lookupEnd.index + 2)) ?? data; + } else { + // get the value at the index: + const indexAsNumber = parseInt(entryPointer); + if (isNaN(indexAsNumber)) return undefined; + const data = value[indexAsNumber]; + // Check for safety: + if (lookupEnd.index === undefined || lookupEnd.index + 1 >= rest.length) { + return data; + } + // continue with the rest of the path: + return GetNextPropertyValueFromPath(data, rest.slice(lookupEnd.index + 2)) ?? data; + } + } else { + // continue with the rest of the path: + return GetNextPropertyValueFromPath(value, rest); + } +} + +/** + * + * @param filter + */ +function JsFilterFromJsonPathFilter(filter: string): any { + // strip ?( and ) from the filter + const jsFilter = filter.slice(2, -1); + // split the filter into parts by splitting at ' && ' + const parts = jsFilter.split(' && '); + // map each part to a function that returns true if the part is true + return parts.map((part) => { + // split the part into key and value + const [path, equal] = part.split(' = '); + // remove @. + const key = path.slice(2); + // remove quotes: + const value = equal.slice(1, -1); + // return a function that returns true if the key is equal to the value + return (item: any) => item[key] === value; + }); +} diff --git a/src/packages/core/validation/utils/json-path.test.ts b/src/packages/core/validation/utils/json-path.test.ts new file mode 100644 index 0000000000..3673b28ecd --- /dev/null +++ b/src/packages/core/validation/utils/json-path.test.ts @@ -0,0 +1,37 @@ +import { expect } from '@open-wc/testing'; +import { GetValueByJsonPath } from './json-path.function.js'; + +describe('UmbJsonPathFunctions', () => { + it('retrieve property value', () => { + const result = GetValueByJsonPath({ value: 'test' }, '$.value'); + + expect(result).to.eq('test'); + }); + + it('value of first entry in an array', () => { + const result = GetValueByJsonPath({ values: ['test'] }, '$.values[0]'); + + expect(result).to.eq('test'); + }); + + it('value property of first entry in an array', () => { + const result = GetValueByJsonPath({ values: [{ value: 'test' }] }, '$.values[0].value'); + + expect(result).to.eq('test'); + }); + + it('value property of first entry in an array', () => { + const result = GetValueByJsonPath( + { values: [{ value: { deepData: [{ value: 'inner' }] } }] }, + '$.values[0].value.deepData[0].value', + ); + + expect(result).to.eq('inner'); + }); + + it('query of first entry in an array', () => { + const result = GetValueByJsonPath({ values: [{ id: '123', value: 'test' }] }, "$.values[?(@.id = '123')].value"); + + expect(result).to.eq('test'); + }); +}); diff --git a/src/packages/core/variant/variant-id.class.ts b/src/packages/core/variant/variant-id.class.ts index ce012c8c61..dfac1d8762 100644 --- a/src/packages/core/variant/variant-id.class.ts +++ b/src/packages/core/variant/variant-id.class.ts @@ -81,15 +81,19 @@ export class UmbVariantId { // TODO: needs localization option: // TODO: Consider if this should be handled else where, it does not seem like the responsibility of this class, since it contains wordings: - public toDifferencesString(variantId: UmbVariantId): string { + public toDifferencesString( + variantId: UmbVariantId, + invariantMessage: string = 'Invariant', + unsegmentedMessage: string = 'Unsegmented', + ): string { let r = ''; if (variantId.culture !== this.culture) { - r = 'Invariant'; + r = invariantMessage; } if (variantId.segment !== this.segment) { - r = (r !== '' ? ' ' : '') + 'Unsegmented'; + r = (r !== '' ? ' ' : '') + unsegmentedMessage; } return r; diff --git a/src/packages/core/workspace/components/workspace-editor/workspace-editor.element.ts b/src/packages/core/workspace/components/workspace-editor/workspace-editor.element.ts index 15639d6ed7..79a7b63072 100644 --- a/src/packages/core/workspace/components/workspace-editor/workspace-editor.element.ts +++ b/src/packages/core/workspace/components/workspace-editor/workspace-editor.element.ts @@ -19,7 +19,6 @@ import type { UmbRoute, UmbRouterSlotInitEvent, UmbRouterSlotChangeEvent } from * @class UmbWorkspaceEditor * @augments {UmbLitElement} */ -// TODO: This element has a bug in the tabs. After the url changes - for example a new entity/file is chosen in the tree and loaded to the workspace the links in the tabs still point to the previous url and therefore views do not change correctly @customElement('umb-workspace-editor') export class UmbWorkspaceEditorElement extends UmbLitElement { @property() diff --git a/src/packages/core/workspace/components/workspace-split-view/workspace-split-view-variant-selector.element.ts b/src/packages/core/workspace/components/workspace-split-view/workspace-split-view-variant-selector.element.ts index c4bfbf0cab..7a9bce0fc7 100644 --- a/src/packages/core/workspace/components/workspace-split-view/workspace-split-view-variant-selector.element.ts +++ b/src/packages/core/workspace/components/workspace-split-view/workspace-split-view-variant-selector.element.ts @@ -12,6 +12,7 @@ import { UmbVariantId } from '@umbraco-cms/backoffice/variant'; import { UMB_PROPERTY_DATASET_CONTEXT, isNameablePropertyDatasetContext } from '@umbraco-cms/backoffice/property'; import { UmbLitElement, umbFocus } from '@umbraco-cms/backoffice/lit-element'; import { UmbTextStyles } from '@umbraco-cms/backoffice/style'; +import { UmbDataPathVariantQuery, umbBindToValidation } from '@umbraco-cms/backoffice/validation'; type UmbDocumentVariantOption = { culture: string | null; @@ -45,6 +46,9 @@ export class UmbWorkspaceSplitViewVariantSelectorElement extends UmbLitElement { @state() private _name?: string; + @state() + private _variantId?: UmbVariantId; + @state() private _variantDisplayName = ''; @@ -132,11 +136,11 @@ export class UmbWorkspaceSplitViewVariantSelectorElement extends UmbLitElement { const workspaceContext = this.#splitViewContext.getWorkspaceContext(); if (!workspaceContext) return; - const variantId = this.#datasetContext.getVariantId(); + this._variantId = this.#datasetContext.getVariantId(); // Find the variant option matching this, to get the language name... - const culture = variantId.culture; - const segment = variantId.segment; + const culture = this._variantId.culture; + const segment = this._variantId.segment; this.observe( workspaceContext.variantOptions, @@ -209,12 +213,15 @@ export class UmbWorkspaceSplitViewVariantSelectorElement extends UmbLitElement { } override render() { - return html` + return this._variantId + ? html` ${ @@ -287,7 +294,8 @@ export class UmbWorkspaceSplitViewVariantSelectorElement extends UmbLitElement { : nothing }
- `; + ` + : nothing; } static override styles = [ diff --git a/src/packages/core/workspace/contexts/submittable-workspace-context-base.ts b/src/packages/core/workspace/contexts/submittable-workspace-context-base.ts index b98584e961..ccc13c9526 100644 --- a/src/packages/core/workspace/contexts/submittable-workspace-context-base.ts +++ b/src/packages/core/workspace/contexts/submittable-workspace-context-base.ts @@ -7,7 +7,7 @@ import { UmbBooleanState } from '@umbraco-cms/backoffice/observable-api'; import type { UmbModalContext } from '@umbraco-cms/backoffice/modal'; import { UMB_MODAL_CONTEXT } from '@umbraco-cms/backoffice/modal'; import type { Observable } from '@umbraco-cms/backoffice/external/rxjs'; -import { UmbValidationContext } from '@umbraco-cms/backoffice/validation'; +import type { UmbValidationContext } from '@umbraco-cms/backoffice/validation'; export abstract class UmbSubmittableWorkspaceContextBase extends UmbContextBase> @@ -18,7 +18,16 @@ export abstract class UmbSubmittableWorkspaceContextBase // TODO: We could make a base type for workspace modal data, and use this here: As well as a base for the result, to make sure we always include the unique (instead of the object type) public readonly modalContext?: UmbModalContext<{ preset: object }>; - public readonly validation = new UmbValidationContext(this); + //public readonly validation = new UmbValidationContext(this); + #validationContexts: Array = []; + + /** + * Appends a validation context to the workspace. + * @param context + */ + addValidationContext(context: UmbValidationContext) { + this.#validationContexts.push(context); + } #submitPromise: Promise | undefined; #submitResolve: (() => void) | undefined; @@ -42,14 +51,15 @@ export abstract class UmbSubmittableWorkspaceContextBase constructor(host: UmbControllerHost, workspaceAlias: string) { super(host, UMB_WORKSPACE_CONTEXT.toString()); this.workspaceAlias = workspaceAlias; - // TODO: Consider if we can turn this consumption to submitComplete, just as a getContext. [NL] + // TODO: Consider if we can move this consumption to #resolveSubmit, just as a getContext, but it depends if others use the modalContext prop.. [NL] this.consumeContext(UMB_MODAL_CONTEXT, (context) => { (this.modalContext as UmbModalContext) = context; }); } protected resetState() { - this.validation.reset(); + //this.validation.reset(); + this.#validationContexts.forEach((context) => context.reset()); this.#isNew.setValue(undefined); } @@ -61,6 +71,15 @@ export abstract class UmbSubmittableWorkspaceContextBase this.#isNew.setValue(isNew); } + /** + * If a Workspace has multiple validation contexts, then this method can be overwritten to return the correct one. + * @returns Promise that resolves to void when the validation is complete. + */ + async validate(): Promise> { + //return this.validation.validate(); + return Promise.all(this.#validationContexts.map((context) => context.validate())); + } + async requestSubmit(): Promise { return this.validateAndSubmit( () => this.submit(), @@ -76,7 +95,7 @@ export abstract class UmbSubmittableWorkspaceContextBase this.#submitResolve = resolve; this.#submitReject = reject; }); - this.validation.validate().then( + this.validate().then( async () => { onValid().then(this.#completeSubmit, this.#rejectSubmit); }, @@ -90,6 +109,8 @@ export abstract class UmbSubmittableWorkspaceContextBase #rejectSubmit = () => { if (this.#submitPromise) { + // TODO: Capture the validation contexts messages on open, and then reset to them in this case. [NL] + this.#submitReject?.(); this.#submitPromise = undefined; this.#submitResolve = undefined; @@ -115,7 +136,8 @@ export abstract class UmbSubmittableWorkspaceContextBase this.#resolveSubmit(); // Calling reset on the validation context here. [NL] - this.validation.reset(); + // TODO: Capture the validation messages on open, and then reset to that. + //this.validation.reset(); }; //abstract getIsDirty(): Promise; diff --git a/src/packages/data-type/components/data-type-flow-input/data-type-flow-input.element.ts b/src/packages/data-type/components/data-type-flow-input/data-type-flow-input.element.ts index 0b433b76ed..f30d2a64d4 100644 --- a/src/packages/data-type/components/data-type-flow-input/data-type-flow-input.element.ts +++ b/src/packages/data-type/components/data-type-flow-input/data-type-flow-input.element.ts @@ -1,10 +1,10 @@ import { UMB_DATA_TYPE_PICKER_FLOW_MODAL } from '../../modals/index.js'; import { UMB_DATATYPE_WORKSPACE_MODAL } from '../../workspace/index.js'; -import { css, html, customElement, property, state } from '@umbraco-cms/backoffice/external/lit'; -import { UUIFormControlMixin } from '@umbraco-cms/backoffice/external/uui'; +import { css, html, customElement, property, state, type PropertyValueMap } from '@umbraco-cms/backoffice/external/lit'; import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; import { UmbModalRouteRegistrationController } from '@umbraco-cms/backoffice/router'; import { UmbChangeEvent } from '@umbraco-cms/backoffice/event'; +import { UMB_VALIDATION_EMPTY_LOCALIZATION_KEY, UmbFormControlMixin } from '@umbraco-cms/backoffice/validation'; // Note: Does only support picking a single data type. But this could be developed later into this same component. To follow other picker input components. /** @@ -15,7 +15,7 @@ import { UmbChangeEvent } from '@umbraco-cms/backoffice/event'; * @fires focus - when the input gains focus */ @customElement('umb-data-type-flow-input') -export class UmbInputDataTypeElement extends UUIFormControlMixin(UmbLitElement, '') { +export class UmbInputDataTypeElement extends UmbFormControlMixin(UmbLitElement, '') { protected override getFormElement() { return undefined; } @@ -66,6 +66,20 @@ export class UmbInputDataTypeElement extends UUIFormControlMixin(UmbLitElement, }); } + protected override firstUpdated(_changedProperties: PropertyValueMap | Map): void { + super.firstUpdated(_changedProperties); + + this.addValidator( + 'valueMissing', + () => UMB_VALIDATION_EMPTY_LOCALIZATION_KEY, + () => this.hasAttribute('required') && !this.value, + ); + } + + override focus() { + this.shadowRoot?.querySelector('umb-ref-data-type')?.focus(); + } + override render() { return this._ids && this._ids.length > 0 ? html` @@ -87,6 +101,9 @@ export class UmbInputDataTypeElement extends UUIFormControlMixin(UmbLitElement, label="Select Property Editor" look="placeholder" color="default" + @focus=${() => { + this.pristine = false; + }} .href=${this._createRoute}> `; } @@ -98,6 +115,11 @@ export class UmbInputDataTypeElement extends UUIFormControlMixin(UmbLitElement, --uui-button-padding-top-factor: 4; --uui-button-padding-bottom-factor: 4; } + :host(:invalid:not([pristine])) #empty-state-button { + --uui-button-border-color: var(--uui-color-danger); + --uui-button-border-width: 2px; + --uui-button-contrast: var(--uui-color-danger); + } `, ]; } diff --git a/src/packages/data-type/components/property-editor-config/property-editor-config.element.ts b/src/packages/data-type/components/property-editor-config/property-editor-config.element.ts index 46a9136d23..2fb4bb1ffc 100644 --- a/src/packages/data-type/components/property-editor-config/property-editor-config.element.ts +++ b/src/packages/data-type/components/property-editor-config/property-editor-config.element.ts @@ -3,7 +3,7 @@ import { html, customElement, state, ifDefined, repeat } from '@umbraco-cms/back import { UmbTextStyles } from '@umbraco-cms/backoffice/style'; import type { PropertyEditorSettingsProperty } from '@umbraco-cms/backoffice/extension-registry'; import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; -import { UmbDataPathPropertyValueFilter } from '@umbraco-cms/backoffice/validation'; +import { UmbDataPathPropertyValueQuery } from '@umbraco-cms/backoffice/validation'; /** * @element umb-property-editor-config @@ -46,7 +46,7 @@ export class UmbPropertyEditorConfigElement extends UmbLitElement { (property) => property.alias, (property) => html` `; diff --git a/src/packages/data-type/workspace/data-type-workspace.context.ts b/src/packages/data-type/workspace/data-type-workspace.context.ts index 49aa80140f..46bb5ec6a3 100644 --- a/src/packages/data-type/workspace/data-type-workspace.context.ts +++ b/src/packages/data-type/workspace/data-type-workspace.context.ts @@ -28,6 +28,7 @@ import { UmbRequestReloadChildrenOfEntityEvent, UmbRequestReloadStructureForEntityEvent, } from '@umbraco-cms/backoffice/entity-action'; +import { UmbValidationContext } from '@umbraco-cms/backoffice/validation'; type EntityType = UmbDataTypeDetailModel; @@ -99,6 +100,8 @@ export class UmbDataTypeWorkspaceContext constructor(host: UmbControllerHost) { super(host, 'Umb.Workspace.DataType'); + this.addValidationContext(new UmbValidationContext(this).provide()); + this.#observePropertyEditorSchemaAlias(); this.#observePropertyEditorUIAlias(); diff --git a/src/packages/data-type/workspace/views/details/data-type-details-workspace-view.element.ts b/src/packages/data-type/workspace/views/details/data-type-details-workspace-view.element.ts index b107d79176..b6f5ba92a0 100644 --- a/src/packages/data-type/workspace/views/details/data-type-details-workspace-view.element.ts +++ b/src/packages/data-type/workspace/views/details/data-type-details-workspace-view.element.ts @@ -4,6 +4,7 @@ import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; import { UmbTextStyles } from '@umbraco-cms/backoffice/style'; import { UMB_MODAL_MANAGER_CONTEXT, UMB_PROPERTY_EDITOR_UI_PICKER_MODAL } from '@umbraco-cms/backoffice/modal'; import type { UmbWorkspaceViewElement } from '@umbraco-cms/backoffice/extension-registry'; +import { umbBindToValidation } from '@umbraco-cms/backoffice/validation'; @customElement('umb-data-type-details-workspace-view') export class UmbDataTypeDetailsWorkspaceViewEditElement extends UmbLitElement implements UmbWorkspaceViewElement { @@ -71,11 +72,9 @@ export class UmbDataTypeDetailsWorkspaceViewEditElement extends UmbLitElement im override render() { return html` - - ${this._propertyEditorUiAlias && this._propertyEditorSchemaAlias - ? this.#renderPropertyEditorReference() - : this.#renderChooseButton()} - + ${this._propertyEditorUiAlias && this._propertyEditorSchemaAlias + ? this.#renderPropertyEditorReference() + : this.#renderChooseButton()} ${this.#renderSettings()} `; @@ -90,37 +89,44 @@ export class UmbDataTypeDetailsWorkspaceViewEditElement extends UmbLitElement im `; } + // Notice, we have implemented a property-layout for each states of the property editor ui picker, in this way the validation message gets removed once the choose-button is gone. (As we are missing ability to detect if elements got removed from DOM)[NL] #renderChooseButton() { return html` - + + + `; } #renderPropertyEditorReference() { if (!this._propertyEditorUiAlias || !this._propertyEditorSchemaAlias) return nothing; return html` - - ${this._propertyEditorUiIcon - ? html`` - : nothing} - - - - + + + ${this._propertyEditorUiIcon + ? html`` + : nothing} + + + + + `; } diff --git a/src/packages/documents/document-types/workspace/document-type-workspace-editor.element.ts b/src/packages/documents/document-types/workspace/document-type-workspace-editor.element.ts index c41cabacbe..18f4a74ced 100644 --- a/src/packages/documents/document-types/workspace/document-type-workspace-editor.element.ts +++ b/src/packages/documents/document-types/workspace/document-type-workspace-editor.element.ts @@ -4,6 +4,7 @@ import { umbFocus, UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; import { css, html, customElement, state, ifDefined } from '@umbraco-cms/backoffice/external/lit'; import { UMB_MODAL_MANAGER_CONTEXT, UMB_ICON_PICKER_MODAL } from '@umbraco-cms/backoffice/modal'; import type { UUITextareaElement } from '@umbraco-cms/backoffice/external/uui'; +import { umbBindToValidation } from '@umbraco-cms/backoffice/validation'; @customElement('umb-document-type-workspace-editor') export class UmbDocumentTypeWorkspaceEditorElement extends UmbLitElement { @@ -86,10 +87,12 @@ export class UmbDocumentTypeWorkspaceEditorElement extends UmbLitElement { diff --git a/src/packages/documents/document-types/workspace/document-type-workspace.context.ts b/src/packages/documents/document-types/workspace/document-type-workspace.context.ts index d332df01e3..65ab3f5c5f 100644 --- a/src/packages/documents/document-types/workspace/document-type-workspace.context.ts +++ b/src/packages/documents/document-types/workspace/document-type-workspace.context.ts @@ -30,6 +30,7 @@ import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; import type { UmbReferenceByUnique } from '@umbraco-cms/backoffice/models'; import type { UmbRoutableWorkspaceContext } from '@umbraco-cms/backoffice/workspace'; import type { UmbPathPatternTypeAsEncodedParamsType } from '@umbraco-cms/backoffice/router'; +import { UmbValidationContext } from '@umbraco-cms/backoffice/validation'; type EntityType = UmbDocumentTypeDetailModel; export class UmbDocumentTypeWorkspaceContext @@ -79,6 +80,8 @@ export class UmbDocumentTypeWorkspaceContext constructor(host: UmbControllerHost) { super(host, 'Umb.Workspace.DocumentType'); + this.addValidationContext(new UmbValidationContext(this).provide()); + // General for content types: //this.data = this.structure.ownerContentType; diff --git a/src/packages/documents/documents/workspace/document-workspace.context.ts b/src/packages/documents/documents/workspace/document-workspace.context.ts index df3d705077..719e6d6719 100644 --- a/src/packages/documents/documents/workspace/document-workspace.context.ts +++ b/src/packages/documents/documents/workspace/document-workspace.context.ts @@ -52,7 +52,15 @@ import { UmbRequestReloadStructureForEntityEvent, } from '@umbraco-cms/backoffice/entity-action'; import { UMB_MODAL_MANAGER_CONTEXT } from '@umbraco-cms/backoffice/modal'; -import { UmbServerModelValidationContext } from '@umbraco-cms/backoffice/validation'; +import { + UMB_VALIDATION_CONTEXT, + UMB_VALIDATION_EMPTY_LOCALIZATION_KEY, + UmbDataPathVariantQuery, + UmbServerModelValidatorContext, + UmbValidationContext, + UmbVariantValuesValidationPathTranslator, + UmbVariantsValidationPathTranslator, +} from '@umbraco-cms/backoffice/validation'; import { UmbDocumentBlueprintDetailRepository } from '@umbraco-cms/backoffice/document-blueprint'; import { UMB_NOTIFICATION_CONTEXT } from '@umbraco-cms/backoffice/notification'; import type { UmbContentWorkspaceContext } from '@umbraco-cms/backoffice/content'; @@ -87,7 +95,7 @@ export class UmbDocumentWorkspaceContext #languages = new UmbArrayState([], (x) => x.unique); public readonly languages = this.#languages.asObservable(); - #serverValidation = new UmbServerModelValidationContext(this); + #serverValidation = new UmbServerModelValidatorContext(this); #validationRepository?: UmbDocumentValidationRepository; #blueprintRepository = new UmbDocumentBlueprintDetailRepository(this); @@ -159,6 +167,11 @@ export class UmbDocumentWorkspaceContext constructor(host: UmbControllerHost) { super(host, UMB_DOCUMENT_WORKSPACE_ALIAS); + this.addValidationContext(new UmbValidationContext(this).provide()); + + new UmbVariantValuesValidationPathTranslator(this); + new UmbVariantsValidationPathTranslator(this); + this.observe(this.contentTypeUnique, (unique) => this.structure.loadType(unique)); this.observe(this.varies, (varies) => (this.#varies = varies)); @@ -572,6 +585,7 @@ export class UmbDocumentWorkspaceContext async #performSaveOrCreate(saveData: UmbDocumentDetailModel): Promise { if (this.getIsNew()) { + // Create: const parent = this.#parent.getValue(); if (!parent) throw new Error('Parent is not set'); @@ -592,6 +606,7 @@ export class UmbDocumentWorkspaceContext }); eventContext.dispatchEvent(event); } else { + // Save: const { data, error } = await this.repository.save(saveData); if (!data || error) { console.error('Error saving document', error); @@ -603,8 +618,8 @@ export class UmbDocumentWorkspaceContext const eventContext = await this.getContext(UMB_ACTION_EVENT_CONTEXT); const event = new UmbRequestReloadStructureForEntityEvent({ - unique: this.getUnique()!, entityType: this.getEntityType(), + unique: this.getUnique()!, }); eventContext.dispatchEvent(event); @@ -623,6 +638,7 @@ export class UmbDocumentWorkspaceContext culture = selected[0]; const variantId = UmbVariantId.FromString(culture); const saveData = this.#buildSaveData([variantId]); + await this.#runMandatoryValidationForSaveData(saveData); await this.#performSaveOrCreate(saveData); } @@ -666,6 +682,7 @@ export class UmbDocumentWorkspaceContext } const saveData = this.#buildSaveData(variantIds); + await this.#runMandatoryValidationForSaveData(saveData); // Create the validation repository if it does not exist. (we first create this here when we need it) [NL] this.#validationRepository ??= new UmbDocumentValidationRepository(this); @@ -703,6 +720,23 @@ export class UmbDocumentWorkspaceContext ); } + async #runMandatoryValidationForSaveData(saveData: UmbDocumentDetailModel) { + // Check that the data is valid before we save it. + // Check variants have a name: + const variantsWithoutAName = saveData.variants.filter((x) => !x.name); + if (variantsWithoutAName.length > 0) { + const validationContext = await this.getContext(UMB_VALIDATION_CONTEXT); + variantsWithoutAName.forEach((variant) => { + validationContext.messages.addMessage( + 'client', + `$.variants[${UmbDataPathVariantQuery(variant)}].name`, + UMB_VALIDATION_EMPTY_LOCALIZATION_KEY, + ); + }); + throw new Error('All variants must have a name'); + } + } + async #performSaveAndPublish(variantIds: Array, saveData: UmbDocumentDetailModel): Promise { const unique = this.getUnique(); if (!unique) throw new Error('Unique is missing'); @@ -753,6 +787,7 @@ export class UmbDocumentWorkspaceContext } const saveData = this.#buildSaveData(variantIds); + await this.#runMandatoryValidationForSaveData(saveData); return await this.#performSaveOrCreate(saveData); } diff --git a/src/packages/property-editors/text-box/property-editor-ui-text-box.element.ts b/src/packages/property-editors/text-box/property-editor-ui-text-box.element.ts index 2e3397c100..4cac623483 100644 --- a/src/packages/property-editors/text-box/property-editor-ui-text-box.element.ts +++ b/src/packages/property-editors/text-box/property-editor-ui-text-box.element.ts @@ -7,7 +7,7 @@ import { type UmbPropertyEditorConfigCollection, } from '@umbraco-cms/backoffice/property-editor'; import type { UUIInputElement } from '@umbraco-cms/backoffice/external/uui'; -import { UmbFormControlMixin } from '@umbraco-cms/backoffice/validation'; +import { UMB_VALIDATION_EMPTY_LOCALIZATION_KEY, UmbFormControlMixin } from '@umbraco-cms/backoffice/validation'; type UuiInputTypeType = typeof UUIInputElement.prototype.type; @@ -25,6 +25,15 @@ export class UmbPropertyEditorUITextBoxElement @property({ type: Boolean, reflect: true }) readonly = false; + /** + * Sets the input to mandatory, meaning validation will fail if the value is empty. + * @type {boolean} + */ + @property({ type: Boolean }) + mandatory?: boolean; + @property({ type: String }) + mandatoryMessage = UMB_VALIDATION_EMPTY_LOCALIZATION_KEY; + #defaultType: UuiInputTypeType = 'text'; @state() @@ -50,6 +59,10 @@ export class UmbPropertyEditorUITextBoxElement this.addFormControlElement(this.shadowRoot!.querySelector('uui-input')!); } + override focus() { + return this.shadowRoot?.querySelector('uui-input')?.focus(); + } + #onInput(e: InputEvent) { const newValue = (e.target as HTMLInputElement).value; if (newValue === this.value) return; @@ -65,6 +78,8 @@ export class UmbPropertyEditorUITextBoxElement inputMode=${ifDefined(this._inputMode)} maxlength=${ifDefined(this._maxChars)} @input=${this.#onInput} + ?required=${this.mandatory} + .requiredMessage=${this.mandatoryMessage} ?readonly=${this.readonly}>`; }