diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/property/components/property/property.element.ts b/src/Umbraco.Web.UI.Client/src/packages/core/property/components/property/property.element.ts index 1ead621347..d21f1dcd51 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/property/components/property/property.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/property/components/property/property.element.ts @@ -28,7 +28,6 @@ import { UmbRoutePathAddendumContext } from '@umbraco-cms/backoffice/router'; * The Element will render a Property Editor based on the Property Editor UI alias passed to the element. * This will also render all Property Actions related to the Property Editor UI Alias. */ - @customElement('umb-property') export class UmbPropertyElement extends UmbLitElement { /** @@ -178,6 +177,7 @@ export class UmbPropertyElement extends UmbLitElement { #validationMessageBinder?: UmbBindServerValidationToFormControl; #valueObserver?: UmbObserverController; #configObserver?: UmbObserverController; + #validationMessageObserver?: UmbObserverController; #extensionsController?: UmbExtensionsApiInitializer; constructor() { @@ -293,6 +293,7 @@ export class UmbPropertyElement extends UmbLitElement { // cleanup: this.#valueObserver?.destroy(); this.#configObserver?.destroy(); + this.#validationMessageObserver?.destroy(); this.#controlValidator?.destroy(); oldElement?.removeEventListener('change', this._onPropertyEditorChange as any as EventListener); oldElement?.removeEventListener('property-value-change', this._onPropertyEditorChange as any as EventListener); @@ -330,7 +331,7 @@ export class UmbPropertyElement extends UmbLitElement { }, null, ); - this.#configObserver = this.observe( + this.#validationMessageObserver = this.observe( this.#propertyContext.validationMandatoryMessage, (mandatoryMessage) => { if (mandatoryMessage) { diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/repository/repository-items.manager.ts b/src/Umbraco.Web.UI.Client/src/packages/core/repository/repository-items.manager.ts index c8b2e5b8bd..3992d118ef 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/repository/repository-items.manager.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/repository/repository-items.manager.ts @@ -76,8 +76,8 @@ export class UmbRepositoryItemsManager exte return this.#uniques.getValue(); } - setUniques(uniques: string[]): void { - this.#uniques.setValue(uniques); + setUniques(uniques: string[] | undefined): void { + this.#uniques.setValue(uniques ?? []); } getItems(): Array { diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/sorter/sorter.controller.ts b/src/Umbraco.Web.UI.Client/src/packages/core/sorter/sorter.controller.ts index b90c7c1614..ef285d0b90 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/sorter/sorter.controller.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/sorter/sorter.controller.ts @@ -221,7 +221,7 @@ export type UmbSorterConfig = Partial, 'ignorerSelector' | 'containerSelector' | 'identifier'>>; /** - + * @class UmbSorterController * @implements {UmbControllerInterface} * @description This controller can make user able to sort items. @@ -346,10 +346,8 @@ export class UmbSorterController): void { - if (this.#model) { - this.#model = model; - } + setModel(model: Array | undefined): void { + this.#model = model ?? []; } /** diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/validation/mixins/form-control.mixin.ts b/src/Umbraco.Web.UI.Client/src/packages/core/validation/mixins/form-control.mixin.ts index 148a9bd599..f4f8438daf 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/validation/mixins/form-control.mixin.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/validation/mixins/form-control.mixin.ts @@ -168,7 +168,6 @@ export function UmbFormControlMixin< /*if (e.composedPath().some((x) => x === this)) { return; }*/ - this.pristine = false; this.checkValidity(); }); } diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/media/components/input-rich-media/input-rich-media.element.ts b/src/Umbraco.Web.UI.Client/src/packages/media/media/components/input-rich-media/input-rich-media.element.ts index 8805e2a30b..f294a22f75 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/media/media/components/input-rich-media/input-rich-media.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/media/media/components/input-rich-media/input-rich-media.element.ts @@ -1,4 +1,3 @@ -import { UmbMediaItemRepository } from '../../repository/index.js'; import { UMB_IMAGE_CROPPER_EDITOR_MODAL, UMB_MEDIA_PICKER_MODAL } from '../../modals/index.js'; import type { UmbMediaItemModel, UmbCropModel, UmbMediaPickerPropertyValueEntry } from '../../types.js'; import type { UmbUploadableItem } from '../../dropzone/types.js'; @@ -9,12 +8,13 @@ import { UmbId } from '@umbraco-cms/backoffice/id'; import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; import { UmbModalRouteRegistrationController } from '@umbraco-cms/backoffice/router'; import { UmbSorterController, UmbSorterResolvePlacementAsGrid } from '@umbraco-cms/backoffice/sorter'; -import { UUIFormControlMixin } from '@umbraco-cms/backoffice/external/uui'; -import type { UmbModalManagerContext } from '@umbraco-cms/backoffice/modal'; import type { UmbModalRouteBuilder } from '@umbraco-cms/backoffice/router'; import type { UmbVariantId } from '@umbraco-cms/backoffice/variant'; import '@umbraco-cms/backoffice/imaging'; +import { UMB_VALIDATION_EMPTY_LOCALIZATION_KEY, UmbFormControlMixin } from '@umbraco-cms/backoffice/validation'; +import { UmbRepositoryItemsManager } from '@umbraco-cms/backoffice/repository'; +import { UMB_MEDIA_ITEM_REPOSITORY_ALIAS } from '@umbraco-cms/backoffice/media'; type UmbRichMediaCardModel = { unique: string; @@ -26,7 +26,11 @@ type UmbRichMediaCardModel = { }; @customElement('umb-input-rich-media') -export class UmbInputRichMediaElement extends UUIFormControlMixin(UmbLitElement, '') { +export class UmbInputRichMediaElement extends UmbFormControlMixin< + Array, + typeof UmbLitElement, + undefined +>(UmbLitElement, undefined) { #sorter = new UmbSorterController(this, { getUniqueOfElement: (element) => { return element.id; @@ -37,24 +41,22 @@ export class UmbInputRichMediaElement extends UUIFormControlMixin(UmbLitElement, identifier: 'Umb.SorterIdentifier.InputRichMedia', itemSelector: 'uui-card-media', containerSelector: '.container', - //resolvePlacement: (args) => args.pointerX < args.relatedRect.left + args.relatedRect.width * 0.5, resolvePlacement: UmbSorterResolvePlacementAsGrid, onChange: ({ model }) => { - this.#items = model; - this.#sortCards(model); + this.value = model; this.dispatchEvent(new UmbChangeEvent()); }, }); - #sortCards(model: Array) { - const idToIndexMap: { [unique: string]: number } = {}; - model.forEach((item, index) => { - idToIndexMap[item.key] = index; - }); + /** + * Sets the input to required, meaning validation will fail if the value is empty. + * @type {boolean} + */ + @property({ type: Boolean }) + required?: boolean; - const cards = [...this._cards]; - this._cards = cards.sort((a, b) => idToIndexMap[a.unique] - idToIndexMap[b.unique]); - } + @property({ type: String }) + requiredMessage?: string; /** * This is a minimum amount of selected items in this input. @@ -93,15 +95,16 @@ export class UmbInputRichMediaElement extends UUIFormControlMixin(UmbLitElement, maxMessage = 'This field exceeds the allowed amount of items'; @property({ type: Array }) - public set items(value: Array) { + public override set value(value: Array | undefined) { + super.value = value; this.#sorter.setModel(value); - this.#items = value; + this.#itemManager.setUniques(value?.map((x) => x.mediaKey)); + // Maybe the new value is using an existing media, and there we need to update the cards despite no repository update. this.#populateCards(); } - public get items(): Array { - return this.#items; + public override get value(): Array | undefined { + return super.value; } - #items: Array = []; @property({ type: Array }) allowedContentTypeIds?: string[] | undefined; @@ -112,11 +115,6 @@ export class UmbInputRichMediaElement extends UUIFormControlMixin(UmbLitElement, @property({ type: Boolean }) multiple = false; - @property() - public override get value() { - return this.items?.map((item) => item.mediaKey).join(','); - } - @property({ type: Array }) public preselectedCrops?: Array; @@ -174,15 +172,17 @@ export class UmbInputRichMediaElement extends UUIFormControlMixin(UmbLitElement, @state() private _routeBuilder?: UmbModalRouteBuilder; - #itemRepository = new UmbMediaItemRepository(this); - - #modalManager?: UmbModalManagerContext; + readonly #itemManager = new UmbRepositoryItemsManager( + this, + UMB_MEDIA_ITEM_REPOSITORY_ALIAS, + (x) => x.unique, + ); constructor() { super(); - this.consumeContext(UMB_MODAL_MANAGER_CONTEXT, (instance) => { - this.#modalManager = instance; + this.observe(this.#itemManager.items, () => { + this.#populateCards(); }); new UmbModalRouteRegistrationController(this, UMB_IMAGE_CROPPER_EDITOR_MODAL) @@ -191,7 +191,7 @@ export class UmbInputRichMediaElement extends UUIFormControlMixin(UmbLitElement, const key = params.key; if (!key) return false; - const item = this.items.find((item) => item.key === key); + const item = this.value?.find((item) => item.key === key); if (!item) return false; return { @@ -212,7 +212,7 @@ export class UmbInputRichMediaElement extends UUIFormControlMixin(UmbLitElement, }; }) .onSubmit((value) => { - this.items = this.items.map((item) => { + this.value = this.value?.map((item) => { if (item.key !== value.key) return item; const focalPoint = this.focalPointEnabled ? value.focalPoint : null; @@ -231,15 +231,30 @@ export class UmbInputRichMediaElement extends UUIFormControlMixin(UmbLitElement, this._routeBuilder = routeBuilder; }); + this.addValidator( + 'valueMissing', + () => this.requiredMessage ?? UMB_VALIDATION_EMPTY_LOCALIZATION_KEY, + () => { + return !this.readonly && !!this.required && (!this.value || this.value.length === 0); + }, + ); + this.addValidator( 'rangeUnderflow', () => this.minMessage, - () => !!this.min && this.items?.length < this.min, + () => + !this.readonly && + // Only if min is set: + !!this.min && + // if the value is empty and not required, we should not validate the min: + !(this.value?.length === 0 && this.required == false) && + // Validate the min: + (this.value?.length ?? 0) < this.min, ); this.addValidator( 'rangeOverflow', () => this.maxMessage, - () => !!this.max && this.items?.length > this.max, + () => !this.readonly && !!this.value && !!this.max && this.value?.length > this.max, ); } @@ -248,28 +263,29 @@ export class UmbInputRichMediaElement extends UUIFormControlMixin(UmbLitElement, } async #populateCards() { - const missingCards = this.items.filter((item) => !this._cards.find((card) => card.unique === item.key)); - if (!missingCards.length) return; + const mediaItems = this.#itemManager.getItems(); - if (!this.items?.length) { + if (!mediaItems.length) { this._cards = []; return; } + // Check if all media items is loaded. + // But notice, it would be nicer UX if we could show a loading state on the cards that are missing(loading) their items. + const missingCards = mediaItems.filter((item) => !this._cards.find((card) => card.unique === item.unique)); + const removedCards = this._cards.filter((card) => !mediaItems.find((item) => card.unique === item.unique)); + if (missingCards.length === 0 && removedCards.length === 0) return; - const uniques = this.items.map((item) => item.mediaKey); - - const { data: items } = await this.#itemRepository.requestItems(uniques); - - this._cards = this.items.map((item) => { - const media = items?.find((x) => x.unique === item.mediaKey); - return { - unique: item.key, - media: item.mediaKey, - name: media?.name ?? '', - icon: media?.mediaType?.icon, - isTrashed: media?.isTrashed ?? false, - }; - }); + this._cards = + this.value?.map((item) => { + const media = mediaItems.find((x) => x.unique === item.mediaKey); + return { + unique: item.key, + media: item.mediaKey, + name: media?.name ?? '', + icon: media?.mediaType?.icon, + isTrashed: media?.isTrashed ?? false, + }; + }) ?? []; } #pickableFilter: (item: UmbMediaItemModel) => boolean = (item) => { @@ -290,12 +306,13 @@ export class UmbInputRichMediaElement extends UUIFormControlMixin(UmbLitElement, focalPoint: null, })); - this.#items = [...this.#items, ...additions]; + this.value = [...(this.value ?? []), ...additions]; this.dispatchEvent(new UmbChangeEvent()); } async #openPicker() { - const modalHandler = this.#modalManager?.open(this, UMB_MEDIA_PICKER_MODAL, { + const modalManager = await this.getContext(UMB_MODAL_MANAGER_CONTEXT); + const modalHandler = modalManager?.open(this, UMB_MEDIA_PICKER_MODAL, { data: { multiple: this.multiple, startNode: this.startNode, @@ -319,8 +336,7 @@ export class UmbInputRichMediaElement extends UUIFormControlMixin(UmbLitElement, confirmLabel: this.localize.term('actions_remove'), }); - this.#items = this.#items.filter((x) => x.key !== item.unique); - this._cards = this._cards.filter((x) => x.unique !== item.unique); + this.value = this.value?.filter((x) => x.key !== item.unique); this.dispatchEvent(new UmbChangeEvent()); } @@ -356,8 +372,7 @@ export class UmbInputRichMediaElement extends UUIFormControlMixin(UmbLitElement, } #renderAddButton() { - // TODO: Stop preventing adding more, instead implement proper validation for user feedback. [NL] - if ((this._cards && this.max && this._cards.length >= this.max) || (this._cards.length && !this.multiple)) return; + if (this._cards && this._cards.length && !this.multiple) return; if (this.readonly && this._cards.length > 0) { return nothing; } else { @@ -365,6 +380,10 @@ export class UmbInputRichMediaElement extends UUIFormControlMixin(UmbLitElement, { + this.pristine = false; + this.checkValidity(); + }} @click=${this.#openPicker} label=${this.localize.term('general_choose')} ?disabled=${this.readonly}> diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/media/property-editors/media-picker/property-editor-ui-media-picker.element.ts b/src/Umbraco.Web.UI.Client/src/packages/media/media/property-editors/media-picker/property-editor-ui-media-picker.element.ts index 2dd6905f69..a02c44c7f9 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/media/media/property-editors/media-picker/property-editor-ui-media-picker.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/media/media/property-editors/media-picker/property-editor-ui-media-picker.element.ts @@ -11,7 +11,7 @@ import type { } from '@umbraco-cms/backoffice/property-editor'; import '../../components/input-rich-media/input-rich-media.element.js'; -import { UmbFormControlMixin } from '@umbraco-cms/backoffice/validation'; +import { UMB_VALIDATION_EMPTY_LOCALIZATION_KEY, UmbFormControlMixin } from '@umbraco-cms/backoffice/validation'; const elementName = 'umb-property-editor-ui-media-picker'; @@ -37,6 +37,16 @@ export class UmbPropertyEditorUIMediaPickerElement this._max = minMax?.max ?? Infinity; } + /** + * 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; + /** * Sets the input to readonly mode, meaning value cannot be changed but still able to read and select its content. * @type {boolean} @@ -82,8 +92,17 @@ export class UmbPropertyEditorUIMediaPickerElement }); } + override firstUpdated() { + this.addFormControlElement(this.shadowRoot!.querySelector('umb-input-rich-media')!); + } + + override focus() { + return this.shadowRoot?.querySelector('umb-input-rich-media')?.focus(); + } + #onChange(event: CustomEvent & { target: UmbInputRichMediaElement }) { - this.value = event.target.items; + const isEmpty = event.target.value?.length === 0; + this.value = isEmpty ? undefined : event.target.value; this.dispatchEvent(new UmbPropertyValueChangeEvent()); } @@ -93,12 +112,14 @@ export class UmbPropertyEditorUIMediaPickerElement .alias=${this._alias} .allowedContentTypeIds=${this._allowedMediaTypes} .focalPointEnabled=${this._focalPointEnabled} - .items=${this.value ?? []} + .value=${this.value ?? []} .max=${this._max} .min=${this._min} .preselectedCrops=${this._preselectedCrops} .startNode=${this._startNode} .variantId=${this._variantId} + .required=${this.mandatory} + .requiredMessage=${this.mandatoryMessage} ?multiple=${this._multiple} @change=${this.#onChange} ?readonly=${this.readonly}> diff --git a/src/Umbraco.Web.UI.Client/src/packages/property-editors/content-picker/property-editor-ui-content-picker.element.ts b/src/Umbraco.Web.UI.Client/src/packages/property-editors/content-picker/property-editor-ui-content-picker.element.ts index 7adc00653e..59209dfc39 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/property-editors/content-picker/property-editor-ui-content-picker.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/property-editors/content-picker/property-editor-ui-content-picker.element.ts @@ -119,6 +119,10 @@ export class UmbPropertyEditorUIContentPickerElement this.#setPickerRootUnique(); } + override focus() { + return this.shadowRoot?.querySelector('umb-input-content')?.focus(); + } + async #setPickerRootUnique() { // If we have a root unique value, we don't need to fetch it from the dynamic root if (this._rootUnique) return;