Merge pull request #18109 from umbraco/v15/bugfix/media-picker-mandatory-validation

Fix: media picker mandatory validation
This commit is contained in:
Niels Lyngsø
2025-01-28 10:26:10 +01:00
committed by GitHub
7 changed files with 112 additions and 70 deletions

View File

@@ -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<unknown>;
#configObserver?: UmbObserverController<UmbPropertyEditorConfigCollection | undefined>;
#validationMessageObserver?: UmbObserverController<string | undefined>;
#extensionsController?: UmbExtensionsApiInitializer<any>;
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) {

View File

@@ -76,8 +76,8 @@ export class UmbRepositoryItemsManager<ItemType extends { unique: string }> exte
return this.#uniques.getValue();
}
setUniques(uniques: string[]): void {
this.#uniques.setValue(uniques);
setUniques(uniques: string[] | undefined): void {
this.#uniques.setValue(uniques ?? []);
}
getItems(): Array<ItemType> {

View File

@@ -221,7 +221,7 @@ export type UmbSorterConfig<T, ElementType extends HTMLElement = HTMLElement> =
Partial<Pick<INTERNAL_UmbSorterConfig<T, ElementType>, 'ignorerSelector' | 'containerSelector' | 'identifier'>>;
/**
* @class UmbSorterController
* @implements {UmbControllerInterface}
* @description This controller can make user able to sort items.
@@ -346,10 +346,8 @@ export class UmbSorterController<T, ElementType extends HTMLElement = HTMLElemen
}
}
setModel(model: Array<T>): void {
if (this.#model) {
this.#model = model;
}
setModel(model: Array<T> | undefined): void {
this.#model = model ?? [];
}
/**

View File

@@ -168,7 +168,6 @@ export function UmbFormControlMixin<
/*if (e.composedPath().some((x) => x === this)) {
return;
}*/
this.pristine = false;
this.checkValidity();
});
}

View File

@@ -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<UmbMediaPickerPropertyValueEntry>,
typeof UmbLitElement,
undefined
>(UmbLitElement, undefined) {
#sorter = new UmbSorterController<UmbMediaPickerPropertyValueEntry>(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<UmbMediaPickerPropertyValueEntry>) {
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<UmbMediaPickerPropertyValueEntry>) {
public override set value(value: Array<UmbMediaPickerPropertyValueEntry> | 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<UmbMediaPickerPropertyValueEntry> {
return this.#items;
public override get value(): Array<UmbMediaPickerPropertyValueEntry> | undefined {
return super.value;
}
#items: Array<UmbMediaPickerPropertyValueEntry> = [];
@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<UmbCropModel>;
@@ -174,15 +172,17 @@ export class UmbInputRichMediaElement extends UUIFormControlMixin(UmbLitElement,
@state()
private _routeBuilder?: UmbModalRouteBuilder;
#itemRepository = new UmbMediaItemRepository(this);
#modalManager?: UmbModalManagerContext;
readonly #itemManager = new UmbRepositoryItemsManager<UmbMediaItemModel>(
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,
<uui-button
id="btn-add"
look="placeholder"
@blur=${() => {
this.pristine = false;
this.checkValidity();
}}
@click=${this.#openPicker}
label=${this.localize.term('general_choose')}
?disabled=${this.readonly}>

View File

@@ -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<UmbInputRichMediaElement>('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}>

View File

@@ -119,6 +119,10 @@ export class UmbPropertyEditorUIContentPickerElement
this.#setPickerRootUnique();
}
override focus() {
return this.shadowRoot?.querySelector<UmbInputContentElement>('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;