Set property value from button click

I have a custom property editor similar to color picker in core here except I just want to set set string value instead of an object: Umbraco-CMS/src/Umbraco.Web.UI.Client/src/packages/property-editors/color-picker/property-editor-ui-color-picker.element.ts at fc60b5b5ffa1db5f9f1ee786f65ad33c9f44f4e3 · umbraco/Umbraco-CMS · GitHub

So I have something like the following:

import { css, html, customElement, property, repeat, state, nothing } from '@umbraco-cms/backoffice/external/lit';
import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';
import type {
	UmbPropertyEditorConfigCollection,
	UmbPropertyEditorUiElement,
} from '@umbraco-cms/backoffice/property-editor';
import { UmbChangeEvent } from '@umbraco-cms/backoffice/event';
import type { McbMultiValuesDetails } from '../../components';

/**
 * @element mcb-property-editor-ui-button-picker
 */
@customElement('mcb-property-editor-ui-button-picker')
export class McbPropertyEditorUIButtonPickerElement extends UmbLitElement implements UmbPropertyEditorUiElement {
	#defaultShowIcons = false;

	@property({ type: String })
	public set value(value: McbMultiValuesDetails | undefined) {
		this.#value = value?.value ?? undefined;
		console.log("set value", value, this.#value);
	}
	public get value(): string | undefined {
		console.log("get value", this.#value);
		return this.#value;
	}
	#value?: string | undefined;

	/**
	 * Sets the input to readonly mode, meaning value cannot be changed but still able to read and select its content.
	 * @type {boolean}
	 * @attr
	 * @default false
	 */
	@property({ type: Boolean, reflect: true })
	readonly = false;

	@state()
	private _showIcons = this.#defaultShowIcons;

	@state()
	private _items: Array<McbMultiValuesDetails> = [];

	public set config(config: UmbPropertyEditorConfigCollection | undefined) {
		if (!config) return;

		this._showIcons = config?.getValueByAlias<boolean>('showIcons') ?? this.#defaultShowIcons;

		this._items = config?.getValueByAlias<Array<McbMultiValuesDetails>>('buttons') ?? [];
	}

	async #selectItem(details: McbMultiValuesDetails) {
		const value = details.value;
		console.log("select", details);
		this.value = this._items.find((item) => item.value === value);
		console.log("value", this.value);
		this.dispatchEvent(new UmbChangeEvent());
	}

	override render() {
		return html`${this.#value}
			${repeat(
				this._items,
				(item) => item.value,
				(item) => html`
					<uui-button
						type="button"
						label=${item.label ?? item.value}
						look=${item.value === this.#value ? 'primary' : 'secondary'}
						@click=${() => this.#selectItem(item)}>
						${!this._showIcons
						? html`<uui-icon slot="icon" name=${item.icon}></uui-icon>`
						: nothing}
						${item.label ?? item.value}
					</uui-button>
				`,
			)}
		`;
	}

	static override styles = [
		css`
			:host {
				display: flex;
				flex-wrap: wrap;
				gap: 0.5rem;
			}
		`,
	];
}

export default McbPropertyEditorUIButtonPickerElement;

declare global {
	interface HTMLElementTagNameMap {
		'mcb-property-editor-ui-button-picker': McbPropertyEditorUIButtonPickerElement;
	}
}

First time I click the button it trigger the click event, but value isn’t set.

But when I click button again, it sets the value as expected and button gets a different look (selected):

What am I missing since value is undefined on first click?

It seems, it fixed the double-click, when I modfied it slightly to set array - inspired from Umbraco-CMS/src/Umbraco.Web.UI.Client/src/packages/property-editors/checkbox-list/property-editor-ui-checkbox-list.element.ts at main · umbraco/Umbraco-CMS · GitHub

import { css, html, customElement, property, repeat, state, nothing } from '@umbraco-cms/backoffice/external/lit';
//import { ensureArray } from '@umbraco-cms/backoffice/property-editor/utils';
import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';
import { UmbFormControlMixin } from '@umbraco-cms/backoffice/validation';
import type {
	UmbPropertyEditorConfigCollection,
	UmbPropertyEditorUiElement,
} from '@umbraco-cms/backoffice/property-editor';
import { UmbChangeEvent } from '@umbraco-cms/backoffice/event';
import type { McbMultiValuesDetails } from '../../components';

/**
 * @element mcb-property-editor-ui-button-picker
 */
@customElement('mcb-property-editor-ui-button-picker')
export class McbPropertyEditorUIButtonPickerElement 
	extends UmbFormControlMixin<Array<string> | string | undefined, typeof UmbLitElement, undefined>(
		UmbLitElement,
		undefined,
	)
	implements UmbPropertyEditorUiElement {
	#defaultShowIcons = false;

	#selection: Array<string> = [];

	@property({ type: Array })
	public override set value(value: Array<string> | string | undefined) {
		this.#selection = this.#ensureArray(value);
	}
	public override get value(): Array<string> | undefined {
		return this.#selection;
	}

	/**
	 * Sets the input to readonly mode, meaning value cannot be changed but still able to read and select its content.
	 * @type {boolean}
	 * @attr
	 * @default false
	 */
	@property({ type: Boolean, reflect: true })
	readonly = false;

	@state()
	private _showIcons = this.#defaultShowIcons;

	@state()
	private _items: Array<McbMultiValuesDetails> = [];

	public set config(config: UmbPropertyEditorConfigCollection | undefined) {
		if (!config) return;

		this._showIcons = config?.getValueByAlias<boolean>('showIcons') ?? this.#defaultShowIcons;

		const items = config?.getValueByAlias<Array<McbMultiValuesDetails>>('buttons') ?? [];

		this._items = items.map((item) => ({ value: item.value, label: item.label?.length ? item.label : null, icon: item.icon } as McbMultiValuesDetails));
	}

	#selectItem(event: Event, details: McbMultiValuesDetails) {
		event.stopPropagation();

		if (!details.value) return;
		const value = details.value;
		this.value = this._items.find((item) => item.value === value)?.value;
		this.dispatchEvent(new UmbChangeEvent());
	}

	#ensureArray(value: string | string[] | null | undefined): string[] {
		return Array.isArray(value) ? value : value ? [value] : [];
	}

	override render() {
		return html`
			${repeat(
				this._items,
				(item) => item.value,
				(item) => html`
					<uui-button
						type="button"
						label=${item.label ?? item.value}
						look=${this.#selection.includes(item.value) ? 'primary' : 'secondary'}
						?disabled=${this.readonly}
						@click=${(event: Event) => this.#selectItem(event, item)}>
						${this._showIcons
							? html`<uui-icon name=${item.icon}></uui-icon>`
							: nothing}
						${item.label ?? item.value}
					</uui-button>
				`,
			)}
		`;
	}

	static override styles = [
		css`
			:host {
				display: flex;
				flex-wrap: wrap;
				gap: 0.5rem;
			}
		`,
	];
}

export default McbPropertyEditorUIButtonPickerElement;

declare global {
	interface HTMLElementTagNameMap {
		'mcb-property-editor-ui-button-picker': McbPropertyEditorUIButtonPickerElement;
	}
}

However it seems this stored a bit different from old backoffice, where the array was stored as string value.

if ($scope.model.config.multiple) {
   $scope.model.value = [];
}
else {
   $scope.model.value = "";
}

In ConvertSourceToIntermediate() the method in value converter returns System.Collections.Generic.List 1[System.String]. I guess System.Text.Json may handle this slightly different from Newtonsoft.Json, which returned a string value IIRC.

Looking at value converter for checkbox, it is much similar way, it handle the property value:

Hi @bjarnef

I would say the problem lies in your getter and setter methods for the value.
A getter and setter method should take and return the same data/type.

The value property and its getter and setter methods should work with the same value-type as you are storing for the Property.

Meaning if you are looking to store a string then the setter method should take a string as its argument.

So change it to this:

public set value(value: string | undefined) {
		this.#value = value
	}

and then in your event handler, you can make it this simple:

async #selectItem(details: McbMultiValuesDetails) {
	this.value = details.value;
	this.dispatchEvent(new UmbChangeEvent());
}

I hope that will make you succeed

Hi Niels

Yes, it wasn’t quite as it worked with Angular, where if I think it serialized [] of items as a string value.

I couldn’t find examples in documentation besides value as string:

I had a look at checkbox list in core, but I am not sure how it actually use #selection to store the value:

I mentioned to Jacob that ensureArray isn’t exported, perhaps I get some time to look at that in my sparetime - and I think it is the same for updateItemsSelectedState (if it makes sense to re-use in custom property editors).

#selection: Array<string> = [];

@property({ type: Array })
public override set value(value: Array<string> | string | undefined) {
	this.#selection = this.#ensureArray(value);
}
public override get value(): Array<string> | undefined {
	return this.#selection;
}

I also needed stopPropagation() here, otherwise it seems to require multiple clicks.

#selectItem(event: Event, details: McbMultiValuesDetails) {
	event.stopPropagation();

	if (!details.value) return;
	const value = details.value;
	this.value = value; //this._items.find((item) => item.value === value)?.value;
	this.dispatchEvent(new UmbChangeEvent());
}

I may need something like thing otherwise storing a comma delimited string:

Looking at the multiple textbox it seems it set value as an array:

Hi @nielslyngsoe

Actually this part was correct:

#selection: Array<string> = [];

@property({ type: Array })
public override set value(value: Array<string> | string | undefined) {
	this.#selection = this.#ensureArray(value);
}
public override get value(): Array<string> | undefined {
	return this.#selection;
}
#selectItem(event: Event, details: McbMultiValuesDetails) {
	event.stopPropagation();

	if (!details.value) return;
	const value = details.value;
	this.value = value; //this._items.find((item) => item.value === value)?.value;
	this.dispatchEvent(new UmbChangeEvent());
}

The issue was in underlying DataValueEditor for property editor.

Previous I re-used MultipleValueEditor from core (as e.g. checkbox list use):

but I couldn’t use this because of the validator, where it expect configuration is an array of string (values) from datatype configuration:

In my use-case I have an array of objects, where each object as value, label (optional) and icon (optional).

So I used as similar approach from color picker, but it failed here (as value is not a single object, but an array of selected values - as in checkbox list):

So I have a combination of these two:

/// <summary>
/// Validates the value for the button picker property editor.
/// </summary>
internal sealed class ConfiguredButtonValidator : IValueValidator
{
    private readonly ILocalizedTextService _localizedTextService;

    /// <summary>
    /// Initializes a new instance of the <see cref="ConfiguredButtonValidator"/> class.
    /// </summary>
    public ConfiguredButtonValidator(ILocalizedTextService localizedTextService) => _localizedTextService = localizedTextService;

    /// <inheritdoc/>
    public IEnumerable<ValidationResult> Validate(object? value, string? valueType, object? dataTypeConfiguration, PropertyValidationContext validationContext)
    {
        if (value == null || value.ToString().IsNullOrWhiteSpace())
        {
            yield break;
        }

        if (dataTypeConfiguration is not ButtonPickerConfiguration buttonPickerConfiguration)
        {
            yield break;
        }

        // Handle validation errors (see e.g. MultipleValueValidator).
    }
}

/// <summary>
///     When multiple values are selected a JSON array will be posted back so we need to format for storage in
///     the database which is a comma separated string value.
/// </summary>
/// <returns></returns>
public override object? FromEditor(ContentPropertyData editorValue, object? currentValue)
{
    if (editorValue.Value is not IEnumerable<string> stringValues || stringValues.Any() == false)
    {
        return null;
    }

    return _jsonSerializer.Serialize(stringValues);
}

It seems to work now :slight_smile:

I have raised this discussion as I think the dropdown, checkbox list, radiobutton list etc. really should support both value/label as minimum (especially with localization to content editors in backoffice). Furthermore developers can eventually re-use the value editor and validator (or perhaps extend from this, e.g. if value/label was used in core, I could just extend this with an icon or a few other properties).

This topic was automatically closed 30 days after the last reply. New replies are no longer allowed.