Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit f2b337a

Browse files
authoredJan 28, 2025··
Merge pull request #18109 from umbraco/v15/bugfix/media-picker-mandatory-validation
Fix: media picker mandatory validation
2 parents c4ab3bd + c72fa48 commit f2b337a

File tree

7 files changed

+113
-71
lines changed

7 files changed

+113
-71
lines changed
 

‎src/Umbraco.Web.UI.Client/src/packages/core/property/components/property/property.element.ts

+3-2
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,6 @@ import { UmbRoutePathAddendumContext } from '@umbraco-cms/backoffice/router';
2828
* The Element will render a Property Editor based on the Property Editor UI alias passed to the element.
2929
* This will also render all Property Actions related to the Property Editor UI Alias.
3030
*/
31-
3231
@customElement('umb-property')
3332
export class UmbPropertyElement extends UmbLitElement {
3433
/**
@@ -178,6 +177,7 @@ export class UmbPropertyElement extends UmbLitElement {
178177
#validationMessageBinder?: UmbBindServerValidationToFormControl;
179178
#valueObserver?: UmbObserverController<unknown>;
180179
#configObserver?: UmbObserverController<UmbPropertyEditorConfigCollection | undefined>;
180+
#validationMessageObserver?: UmbObserverController<string | undefined>;
181181
#extensionsController?: UmbExtensionsApiInitializer<any>;
182182

183183
constructor() {
@@ -293,6 +293,7 @@ export class UmbPropertyElement extends UmbLitElement {
293293
// cleanup:
294294
this.#valueObserver?.destroy();
295295
this.#configObserver?.destroy();
296+
this.#validationMessageObserver?.destroy();
296297
this.#controlValidator?.destroy();
297298
oldElement?.removeEventListener('change', this._onPropertyEditorChange as any as EventListener);
298299
oldElement?.removeEventListener('property-value-change', this._onPropertyEditorChange as any as EventListener);
@@ -330,7 +331,7 @@ export class UmbPropertyElement extends UmbLitElement {
330331
},
331332
null,
332333
);
333-
this.#configObserver = this.observe(
334+
this.#validationMessageObserver = this.observe(
334335
this.#propertyContext.validationMandatoryMessage,
335336
(mandatoryMessage) => {
336337
if (mandatoryMessage) {

‎src/Umbraco.Web.UI.Client/src/packages/core/repository/repository-items.manager.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -76,8 +76,8 @@ export class UmbRepositoryItemsManager<ItemType extends { unique: string }> exte
7676
return this.#uniques.getValue();
7777
}
7878

79-
setUniques(uniques: string[]): void {
80-
this.#uniques.setValue(uniques);
79+
setUniques(uniques: string[] | undefined): void {
80+
this.#uniques.setValue(uniques ?? []);
8181
}
8282

8383
getItems(): Array<ItemType> {

‎src/Umbraco.Web.UI.Client/src/packages/core/sorter/sorter.controller.ts

+3-5
Original file line numberDiff line numberDiff line change
@@ -221,7 +221,7 @@ export type UmbSorterConfig<T, ElementType extends HTMLElement = HTMLElement> =
221221
Partial<Pick<INTERNAL_UmbSorterConfig<T, ElementType>, 'ignorerSelector' | 'containerSelector' | 'identifier'>>;
222222

223223
/**
224-
224+
225225
* @class UmbSorterController
226226
* @implements {UmbControllerInterface}
227227
* @description This controller can make user able to sort items.
@@ -346,10 +346,8 @@ export class UmbSorterController<T, ElementType extends HTMLElement = HTMLElemen
346346
}
347347
}
348348

349-
setModel(model: Array<T>): void {
350-
if (this.#model) {
351-
this.#model = model;
352-
}
349+
setModel(model: Array<T> | undefined): void {
350+
this.#model = model ?? [];
353351
}
354352

355353
/**

‎src/Umbraco.Web.UI.Client/src/packages/core/validation/mixins/form-control.mixin.ts

-1
Original file line numberDiff line numberDiff line change
@@ -168,7 +168,6 @@ export function UmbFormControlMixin<
168168
/*if (e.composedPath().some((x) => x === this)) {
169169
return;
170170
}*/
171-
this.pristine = false;
172171
this.checkValidity();
173172
});
174173
}

‎src/Umbraco.Web.UI.Client/src/packages/media/media/components/input-rich-media/input-rich-media.element.ts

+77-58
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import { UmbMediaItemRepository } from '../../repository/index.js';
21
import { UMB_IMAGE_CROPPER_EDITOR_MODAL, UMB_MEDIA_PICKER_MODAL } from '../../modals/index.js';
32
import type { UmbMediaItemModel, UmbCropModel, UmbMediaPickerPropertyValueEntry } from '../../types.js';
43
import type { UmbUploadableItem } from '../../dropzone/types.js';
@@ -9,12 +8,13 @@ import { UmbId } from '@umbraco-cms/backoffice/id';
98
import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';
109
import { UmbModalRouteRegistrationController } from '@umbraco-cms/backoffice/router';
1110
import { UmbSorterController, UmbSorterResolvePlacementAsGrid } from '@umbraco-cms/backoffice/sorter';
12-
import { UUIFormControlMixin } from '@umbraco-cms/backoffice/external/uui';
13-
import type { UmbModalManagerContext } from '@umbraco-cms/backoffice/modal';
1411
import type { UmbModalRouteBuilder } from '@umbraco-cms/backoffice/router';
1512
import type { UmbVariantId } from '@umbraco-cms/backoffice/variant';
1613

1714
import '@umbraco-cms/backoffice/imaging';
15+
import { UMB_VALIDATION_EMPTY_LOCALIZATION_KEY, UmbFormControlMixin } from '@umbraco-cms/backoffice/validation';
16+
import { UmbRepositoryItemsManager } from '@umbraco-cms/backoffice/repository';
17+
import { UMB_MEDIA_ITEM_REPOSITORY_ALIAS } from '@umbraco-cms/backoffice/media';
1818

1919
type UmbRichMediaCardModel = {
2020
unique: string;
@@ -26,7 +26,11 @@ type UmbRichMediaCardModel = {
2626
};
2727

2828
@customElement('umb-input-rich-media')
29-
export class UmbInputRichMediaElement extends UUIFormControlMixin(UmbLitElement, '') {
29+
export class UmbInputRichMediaElement extends UmbFormControlMixin<
30+
Array<UmbMediaPickerPropertyValueEntry>,
31+
typeof UmbLitElement,
32+
undefined
33+
>(UmbLitElement, undefined) {
3034
#sorter = new UmbSorterController<UmbMediaPickerPropertyValueEntry>(this, {
3135
getUniqueOfElement: (element) => {
3236
return element.id;
@@ -37,24 +41,22 @@ export class UmbInputRichMediaElement extends UUIFormControlMixin(UmbLitElement,
3741
identifier: 'Umb.SorterIdentifier.InputRichMedia',
3842
itemSelector: 'uui-card-media',
3943
containerSelector: '.container',
40-
//resolvePlacement: (args) => args.pointerX < args.relatedRect.left + args.relatedRect.width * 0.5,
4144
resolvePlacement: UmbSorterResolvePlacementAsGrid,
4245
onChange: ({ model }) => {
43-
this.#items = model;
44-
this.#sortCards(model);
46+
this.value = model;
4547
this.dispatchEvent(new UmbChangeEvent());
4648
},
4749
});
4850

49-
#sortCards(model: Array<UmbMediaPickerPropertyValueEntry>) {
50-
const idToIndexMap: { [unique: string]: number } = {};
51-
model.forEach((item, index) => {
52-
idToIndexMap[item.key] = index;
53-
});
51+
/**
52+
* Sets the input to required, meaning validation will fail if the value is empty.
53+
* @type {boolean}
54+
*/
55+
@property({ type: Boolean })
56+
required?: boolean;
5457

55-
const cards = [...this._cards];
56-
this._cards = cards.sort((a, b) => idToIndexMap[a.unique] - idToIndexMap[b.unique]);
57-
}
58+
@property({ type: String })
59+
requiredMessage?: string;
5860

5961
/**
6062
* This is a minimum amount of selected items in this input.
@@ -93,15 +95,16 @@ export class UmbInputRichMediaElement extends UUIFormControlMixin(UmbLitElement,
9395
maxMessage = 'This field exceeds the allowed amount of items';
9496

9597
@property({ type: Array })
96-
public set items(value: Array<UmbMediaPickerPropertyValueEntry>) {
98+
public override set value(value: Array<UmbMediaPickerPropertyValueEntry> | undefined) {
99+
super.value = value;
97100
this.#sorter.setModel(value);
98-
this.#items = value;
101+
this.#itemManager.setUniques(value?.map((x) => x.mediaKey));
102+
// Maybe the new value is using an existing media, and there we need to update the cards despite no repository update.
99103
this.#populateCards();
100104
}
101-
public get items(): Array<UmbMediaPickerPropertyValueEntry> {
102-
return this.#items;
105+
public override get value(): Array<UmbMediaPickerPropertyValueEntry> | undefined {
106+
return super.value;
103107
}
104-
#items: Array<UmbMediaPickerPropertyValueEntry> = [];
105108

106109
@property({ type: Array })
107110
allowedContentTypeIds?: string[] | undefined;
@@ -112,11 +115,6 @@ export class UmbInputRichMediaElement extends UUIFormControlMixin(UmbLitElement,
112115
@property({ type: Boolean })
113116
multiple = false;
114117

115-
@property()
116-
public override get value() {
117-
return this.items?.map((item) => item.mediaKey).join(',');
118-
}
119-
120118
@property({ type: Array })
121119
public preselectedCrops?: Array<UmbCropModel>;
122120

@@ -174,15 +172,17 @@ export class UmbInputRichMediaElement extends UUIFormControlMixin(UmbLitElement,
174172
@state()
175173
private _routeBuilder?: UmbModalRouteBuilder;
176174

177-
#itemRepository = new UmbMediaItemRepository(this);
178-
179-
#modalManager?: UmbModalManagerContext;
175+
readonly #itemManager = new UmbRepositoryItemsManager<UmbMediaItemModel>(
176+
this,
177+
UMB_MEDIA_ITEM_REPOSITORY_ALIAS,
178+
(x) => x.unique,
179+
);
180180

181181
constructor() {
182182
super();
183183

184-
this.consumeContext(UMB_MODAL_MANAGER_CONTEXT, (instance) => {
185-
this.#modalManager = instance;
184+
this.observe(this.#itemManager.items, () => {
185+
this.#populateCards();
186186
});
187187

188188
new UmbModalRouteRegistrationController(this, UMB_IMAGE_CROPPER_EDITOR_MODAL)
@@ -191,7 +191,7 @@ export class UmbInputRichMediaElement extends UUIFormControlMixin(UmbLitElement,
191191
const key = params.key;
192192
if (!key) return false;
193193

194-
const item = this.items.find((item) => item.key === key);
194+
const item = this.value?.find((item) => item.key === key);
195195
if (!item) return false;
196196

197197
return {
@@ -212,7 +212,7 @@ export class UmbInputRichMediaElement extends UUIFormControlMixin(UmbLitElement,
212212
};
213213
})
214214
.onSubmit((value) => {
215-
this.items = this.items.map((item) => {
215+
this.value = this.value?.map((item) => {
216216
if (item.key !== value.key) return item;
217217

218218
const focalPoint = this.focalPointEnabled ? value.focalPoint : null;
@@ -231,15 +231,30 @@ export class UmbInputRichMediaElement extends UUIFormControlMixin(UmbLitElement,
231231
this._routeBuilder = routeBuilder;
232232
});
233233

234+
this.addValidator(
235+
'valueMissing',
236+
() => this.requiredMessage ?? UMB_VALIDATION_EMPTY_LOCALIZATION_KEY,
237+
() => {
238+
return !this.readonly && !!this.required && (!this.value || this.value.length === 0);
239+
},
240+
);
241+
234242
this.addValidator(
235243
'rangeUnderflow',
236244
() => this.minMessage,
237-
() => !!this.min && this.items?.length < this.min,
245+
() =>
246+
!this.readonly &&
247+
// Only if min is set:
248+
!!this.min &&
249+
// if the value is empty and not required, we should not validate the min:
250+
!(this.value?.length === 0 && this.required == false) &&
251+
// Validate the min:
252+
(this.value?.length ?? 0) < this.min,
238253
);
239254
this.addValidator(
240255
'rangeOverflow',
241256
() => this.maxMessage,
242-
() => !!this.max && this.items?.length > this.max,
257+
() => !this.readonly && !!this.value && !!this.max && this.value?.length > this.max,
243258
);
244259
}
245260

@@ -248,28 +263,29 @@ export class UmbInputRichMediaElement extends UUIFormControlMixin(UmbLitElement,
248263
}
249264

250265
async #populateCards() {
251-
const missingCards = this.items.filter((item) => !this._cards.find((card) => card.unique === item.key));
252-
if (!missingCards.length) return;
266+
const mediaItems = this.#itemManager.getItems();
253267

254-
if (!this.items?.length) {
268+
if (!mediaItems.length) {
255269
this._cards = [];
256270
return;
257271
}
258-
259-
const uniques = this.items.map((item) => item.mediaKey);
260-
261-
const { data: items } = await this.#itemRepository.requestItems(uniques);
262-
263-
this._cards = this.items.map((item) => {
264-
const media = items?.find((x) => x.unique === item.mediaKey);
265-
return {
266-
unique: item.key,
267-
media: item.mediaKey,
268-
name: media?.name ?? '',
269-
icon: media?.mediaType?.icon,
270-
isTrashed: media?.isTrashed ?? false,
271-
};
272-
});
272+
// Check if all media items is loaded.
273+
// But notice, it would be nicer UX if we could show a loading state on the cards that are missing(loading) their items.
274+
const missingCards = mediaItems.filter((item) => !this._cards.find((card) => card.unique === item.unique));
275+
const removedCards = this._cards.filter((card) => !mediaItems.find((item) => card.unique === item.unique));
276+
if (missingCards.length === 0 && removedCards.length === 0) return;
277+
278+
this._cards =
279+
this.value?.map((item) => {
280+
const media = mediaItems.find((x) => x.unique === item.mediaKey);
281+
return {
282+
unique: item.key,
283+
media: item.mediaKey,
284+
name: media?.name ?? '',
285+
icon: media?.mediaType?.icon,
286+
isTrashed: media?.isTrashed ?? false,
287+
};
288+
}) ?? [];
273289
}
274290

275291
#pickableFilter: (item: UmbMediaItemModel) => boolean = (item) => {
@@ -290,12 +306,13 @@ export class UmbInputRichMediaElement extends UUIFormControlMixin(UmbLitElement,
290306
focalPoint: null,
291307
}));
292308

293-
this.#items = [...this.#items, ...additions];
309+
this.value = [...(this.value ?? []), ...additions];
294310
this.dispatchEvent(new UmbChangeEvent());
295311
}
296312

297313
async #openPicker() {
298-
const modalHandler = this.#modalManager?.open(this, UMB_MEDIA_PICKER_MODAL, {
314+
const modalManager = await this.getContext(UMB_MODAL_MANAGER_CONTEXT);
315+
const modalHandler = modalManager?.open(this, UMB_MEDIA_PICKER_MODAL, {
299316
data: {
300317
multiple: this.multiple,
301318
startNode: this.startNode,
@@ -319,8 +336,7 @@ export class UmbInputRichMediaElement extends UUIFormControlMixin(UmbLitElement,
319336
confirmLabel: this.localize.term('actions_remove'),
320337
});
321338

322-
this.#items = this.#items.filter((x) => x.key !== item.unique);
323-
this._cards = this._cards.filter((x) => x.unique !== item.unique);
339+
this.value = this.value?.filter((x) => x.key !== item.unique);
324340

325341
this.dispatchEvent(new UmbChangeEvent());
326342
}
@@ -356,15 +372,18 @@ export class UmbInputRichMediaElement extends UUIFormControlMixin(UmbLitElement,
356372
}
357373

358374
#renderAddButton() {
359-
// TODO: Stop preventing adding more, instead implement proper validation for user feedback. [NL]
360-
if ((this._cards && this.max && this._cards.length >= this.max) || (this._cards.length && !this.multiple)) return;
375+
if (this._cards && this._cards.length && !this.multiple) return;
361376
if (this.readonly && this._cards.length > 0) {
362377
return nothing;
363378
} else {
364379
return html`
365380
<uui-button
366381
id="btn-add"
367382
look="placeholder"
383+
@blur=${() => {
384+
this.pristine = false;
385+
this.checkValidity();
386+
}}
368387
@click=${this.#openPicker}
369388
label=${this.localize.term('general_choose')}
370389
?disabled=${this.readonly}>

‎src/Umbraco.Web.UI.Client/src/packages/media/media/property-editors/media-picker/property-editor-ui-media-picker.element.ts

+24-3
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import type {
1111
} from '@umbraco-cms/backoffice/property-editor';
1212

1313
import '../../components/input-rich-media/input-rich-media.element.js';
14-
import { UmbFormControlMixin } from '@umbraco-cms/backoffice/validation';
14+
import { UMB_VALIDATION_EMPTY_LOCALIZATION_KEY, UmbFormControlMixin } from '@umbraco-cms/backoffice/validation';
1515

1616
const elementName = 'umb-property-editor-ui-media-picker';
1717

@@ -37,6 +37,16 @@ export class UmbPropertyEditorUIMediaPickerElement
3737
this._max = minMax?.max ?? Infinity;
3838
}
3939

40+
/**
41+
* Sets the input to mandatory, meaning validation will fail if the value is empty.
42+
* @type {boolean}
43+
*/
44+
@property({ type: Boolean })
45+
mandatory?: boolean;
46+
47+
@property({ type: String })
48+
mandatoryMessage = UMB_VALIDATION_EMPTY_LOCALIZATION_KEY;
49+
4050
/**
4151
* Sets the input to readonly mode, meaning value cannot be changed but still able to read and select its content.
4252
* @type {boolean}
@@ -82,8 +92,17 @@ export class UmbPropertyEditorUIMediaPickerElement
8292
});
8393
}
8494

95+
override firstUpdated() {
96+
this.addFormControlElement(this.shadowRoot!.querySelector('umb-input-rich-media')!);
97+
}
98+
99+
override focus() {
100+
return this.shadowRoot?.querySelector<UmbInputRichMediaElement>('umb-input-rich-media')?.focus();
101+
}
102+
85103
#onChange(event: CustomEvent & { target: UmbInputRichMediaElement }) {
86-
this.value = event.target.items;
104+
const isEmpty = event.target.value?.length === 0;
105+
this.value = isEmpty ? undefined : event.target.value;
87106
this.dispatchEvent(new UmbPropertyValueChangeEvent());
88107
}
89108

@@ -93,12 +112,14 @@ export class UmbPropertyEditorUIMediaPickerElement
93112
.alias=${this._alias}
94113
.allowedContentTypeIds=${this._allowedMediaTypes}
95114
.focalPointEnabled=${this._focalPointEnabled}
96-
.items=${this.value ?? []}
115+
.value=${this.value ?? []}
97116
.max=${this._max}
98117
.min=${this._min}
99118
.preselectedCrops=${this._preselectedCrops}
100119
.startNode=${this._startNode}
101120
.variantId=${this._variantId}
121+
.required=${this.mandatory}
122+
.requiredMessage=${this.mandatoryMessage}
102123
?multiple=${this._multiple}
103124
@change=${this.#onChange}
104125
?readonly=${this.readonly}>

‎src/Umbraco.Web.UI.Client/src/packages/property-editors/content-picker/property-editor-ui-content-picker.element.ts

+4
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,10 @@ export class UmbPropertyEditorUIContentPickerElement
119119
this.#setPickerRootUnique();
120120
}
121121

122+
override focus() {
123+
return this.shadowRoot?.querySelector<UmbInputContentElement>('umb-input-content')?.focus();
124+
}
125+
122126
async #setPickerRootUnique() {
123127
// If we have a root unique value, we don't need to fetch it from the dynamic root
124128
if (this._rootUnique) return;

0 commit comments

Comments
 (0)
Please sign in to comment.